diff --git a/README.md b/README.md index 3d3b732..8b6ca6c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ This update introduces modern **edge-to-edge UI support** and removes deprecated We have implemented full edge-to-edge UI across all screens. +### 2. ✅ Auto & Manual Zoom Support + +- Improved pinch-to-zoom and drag gesture handling. +- Preserves the current zoom and pan position during cropping. +- Prevents zoom reset and image snap-back issues. +- Ensures cropped output matches the visible preview area. + #### 🔹 Key Improvements: - App content now draws behind the **status bar** and **navigation bar** - Improved immersive UI experience @@ -119,7 +126,8 @@ Using these APIs may lead to: .setSupportedFileTypes("mp4", "mkv", "webm", "avi", "flv", "3gp") // Filter by limited media format (Optional) .setMinFileSize(100) // Restrict by minimum file size .setMaxFileSize(1024) // Restrict by maximum file size - .disableCrop() // to remove crop from the single image selection (crop is enabled by default for single image) + // **Note:** Crop-related methods such as `enableFlip()`, `enableRotate()`, `setAspectRatio()`, and `setCropType()`, will not work when `disableCrop()` is enabled. + .disableCrop() // to remove crop from the image selection /* * Configuration for UI */ diff --git a/app/src/main/java/com/lassi/app/MainActivity.kt b/app/src/main/java/com/lassi/app/MainActivity.kt index 998f15b..28e2489 100644 --- a/app/src/main/java/com/lassi/app/MainActivity.kt +++ b/app/src/main/java/com/lassi/app/MainActivity.kt @@ -3,11 +3,13 @@ package com.lassi.app import android.app.Activity import android.content.ContentResolver import android.content.Intent +import android.media.ExifInterface import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.Settings +import android.util.Log import android.view.View import android.webkit.MimeTypeMap import androidx.activity.result.contract.ActivityResultContracts @@ -28,6 +30,7 @@ import com.lassi.presentation.builder.Lassi import com.lassi.presentation.common.decoration.GridSpacingItemDecoration import com.lassi.presentation.cropper.CropImageView import java.io.File +import java.io.IOException import java.util.Locale class MainActivity : AppCompatActivity(), View.OnClickListener { @@ -100,9 +103,9 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { .setProgressBarColor(R.color.colorAccent) .setGalleryBackgroundColor(R.color.colorGrey) .setCropType(CropImageView.CropShape.OVAL).setCropAspectRatio(1, 1) - .setCompressionRatio(10).setMinFileSize(0).setMaxFileSize(Int.MAX_VALUE.toLong()) + .setCompressionRatio(10).setMinFileSize(0) + .setMaxFileSize(Int.MAX_VALUE.toLong()) .enableActualCircleCrop() - .disableCrop() .setSupportedFileTypes("jpg", "jpeg", "png", "webp", "gif").enableFlip() .enableRotate().build() receiveData.launch(intent) @@ -296,6 +299,27 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { it.data?.getParcelableArrayListExtra(KeyUtils.SELECTED_MEDIA) } + selectedMedia?.forEachIndexed { index, miMedia -> + miMedia.path?.let { path -> + try { + val exif = ExifInterface(path) + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + exif.javaClass.declaredFields + .filter { it.name.startsWith("TAG_") } + .forEach { field -> + field.isAccessible = true + val tag = field.get(null) as? String ?: return@forEach + val value = exif.getAttribute(tag) + } + } catch (e: IOException) { + Log.e("EXIF_CHECK", "Failed to read EXIF for $path", e) + } + } + } + if (!selectedMedia.isNullOrEmpty()) { binding.ivEmpty.isVisible = selectedMedia.isEmpty() selectedMediaAdapter.setList(selectedMedia) diff --git a/lassi/build.gradle b/lassi/build.gradle index 421df5f..4c59c04 100644 --- a/lassi/build.gradle +++ b/lassi/build.gradle @@ -14,8 +14,8 @@ android { defaultConfig { minSdk 21 targetSdk 36 - versionCode 32 - versionName "1.5.0" + versionCode 33 + versionName "1.5.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true multiDexEnabled true diff --git a/lassi/src/main/java/com/lassi/domain/media/LassiConfig.kt b/lassi/src/main/java/com/lassi/domain/media/LassiConfig.kt index 17e9d58..5805b46 100644 --- a/lassi/src/main/java/com/lassi/domain/media/LassiConfig.kt +++ b/lassi/src/main/java/com/lassi/domain/media/LassiConfig.kt @@ -46,7 +46,7 @@ data class LassiConfig( var alertDialogNegativeButtonColor: Int = Color.BLACK, var alertDialogPositiveButtonColor: Int = Color.BLACK, var customLimitExceedingErrorMessage: String = ERROR_EXCEEDING_MSG, - var isMultiPicker: Boolean = false + var isMultiPicker: Boolean = false, ) : Parcelable { companion object { diff --git a/lassi/src/main/java/com/lassi/presentation/camera/CameraFragment.kt b/lassi/src/main/java/com/lassi/presentation/camera/CameraFragment.kt index 9a81ee3..08bc664 100644 --- a/lassi/src/main/java/com/lassi/presentation/camera/CameraFragment.kt +++ b/lassi/src/main/java/com/lassi/presentation/camera/CameraFragment.kt @@ -241,8 +241,10 @@ class CameraFragment : LassiBaseViewModelFragment val config = LassiConfig.getConfig() + mediaList = arrayListOf(createMiMedia(uri.path)) croppedMediaList.addAll(config.selectedMedias + mediaList) + croppedMediaList.addAll(config.selectedMedias + mediaList) if (config.compressionRatio > 0 && !config.isCrop) { compressMedia(croppedMediaList) } else { // user has selected the crop option. diff --git a/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.java b/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.java deleted file mode 100644 index 1e70e34..0000000 --- a/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.java +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright 2013 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.lassi.presentation.cameraview.video; - -import android.graphics.SurfaceTexture; -import android.opengl.EGL14; -import android.opengl.EGLConfig; -import android.opengl.EGLContext; -import android.opengl.EGLDisplay; -import android.opengl.EGLExt; -import android.opengl.EGLSurface; -import android.os.Build; -import android.util.Log; -import android.view.Surface; - -import androidx.annotation.RequiresApi; - -/** - * -- from grafika -- - *

- * Core EGL state (display, context, config). - *

- * The EGLContext must only be attached to one thread at a time. This class is not thread-safe. - */ -@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) -public final class EglCore { - /** - * Constructor flag: surface must be recordable. This discourages EGL from using a - * pixel format that cannot be converted efficiently to something usable by the video - * encoder. - */ - public static final int FLAG_RECORDABLE = 0x01; - /** - * Constructor flag: ask for GLES3, fall back to GLES2 if not available. Without this - * flag, GLES2 is used. - */ - public static final int FLAG_TRY_GLES3 = 0x02; - private static final String TAG = EglCore.class.getSimpleName(); - // Android-specific extension. - private static final int EGL_RECORDABLE_ANDROID = 0x3142; - - private EGLDisplay mEGLDisplay = EGL14.EGL_NO_DISPLAY; - private EGLContext mEGLContext = EGL14.EGL_NO_CONTEXT; - private EGLConfig mEGLConfig = null; - private int mGlVersion = -1; - - - /** - * Prepares EGL display and context. - *

- * Equivalent to EglCore(null, 0). - */ - public EglCore() { - this(null, 0); - } - - /** - * Prepares EGL display and context. - *

- * - * @param sharedContext The context to share, or null if sharing is not desired. - * @param flags Configuration bit flags, e.g. FLAG_RECORDABLE. - */ - public EglCore(EGLContext sharedContext, int flags) { - if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { - throw new RuntimeException("EGL already set up"); - } - - if (sharedContext == null) { - sharedContext = EGL14.EGL_NO_CONTEXT; - } - - mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); - if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { - throw new RuntimeException("unable to get EGL14 display"); - } - int[] version = new int[2]; - if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) { - mEGLDisplay = null; - throw new RuntimeException("unable to initialize EGL14"); - } - - // Try to get a GLES3 context, if requested. - if ((flags & FLAG_TRY_GLES3) != 0) { - //Log.d(TAG, "Trying GLES 3"); - EGLConfig config = getConfig(flags, 3); - if (config != null) { - int[] attrib3_list = { - EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, - EGL14.EGL_NONE - }; - EGLContext context = EGL14.eglCreateContext(mEGLDisplay, config, sharedContext, - attrib3_list, 0); - - if (EGL14.eglGetError() == EGL14.EGL_SUCCESS) { - //Log.d(TAG, "Got GLES 3 config"); - mEGLConfig = config; - mEGLContext = context; - mGlVersion = 3; - } - } - } - if (mEGLContext == EGL14.EGL_NO_CONTEXT) { // GLES 2 only, or GLES 3 attempt failed - //Log.d(TAG, "Trying GLES 2"); - EGLConfig config = getConfig(flags, 2); - if (config == null) { - throw new RuntimeException("Unable to find a suitable EGLConfig"); - } - int[] attrib2_list = { - EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, - EGL14.EGL_NONE - }; - EGLContext context = EGL14.eglCreateContext(mEGLDisplay, config, sharedContext, - attrib2_list, 0); - checkEglError("eglCreateContext"); - mEGLConfig = config; - mEGLContext = context; - mGlVersion = 2; - } - - // Confirm with query. - int[] values = new int[1]; - EGL14.eglQueryContext(mEGLDisplay, mEGLContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, - values, 0); - // Log.d(TAG, "EGLContext created, client version " + values[0]); - } - - /** - * Finds a suitable EGLConfig. - * - * @param flags Bit flags from constructor. - * @param version Must be 2 or 3. - */ - private EGLConfig getConfig(int flags, int version) { - int renderableType = EGL14.EGL_OPENGL_ES2_BIT; - if (version >= 3) { - renderableType |= EGLExt.EGL_OPENGL_ES3_BIT_KHR; - } - - // The actual surface is generally RGBA or RGBX, so situationally omitting alpha - // doesn't really help. It can also lead to a huge performance hit on glReadPixels() - // when reading into a GL_RGBA buffer. - int[] attribList = { - EGL14.EGL_RED_SIZE, 8, - EGL14.EGL_GREEN_SIZE, 8, - EGL14.EGL_BLUE_SIZE, 8, - EGL14.EGL_ALPHA_SIZE, 8, - //EGL14.EGL_DEPTH_SIZE, 16, - //EGL14.EGL_STENCIL_SIZE, 8, - EGL14.EGL_RENDERABLE_TYPE, renderableType, - EGL14.EGL_NONE, 0, // placeholder for recordable [@-3] - EGL14.EGL_NONE - }; - if ((flags & FLAG_RECORDABLE) != 0) { - attribList[attribList.length - 3] = EGL_RECORDABLE_ANDROID; - attribList[attribList.length - 2] = 1; - } - EGLConfig[] configs = new EGLConfig[1]; - int[] numConfigs = new int[1]; - if (!EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.length, - numConfigs, 0)) { - Log.w(TAG, "unable to find RGB8888 / " + version + " EGLConfig"); - return null; - } - return configs[0]; - } - - /** - * Discards all resources held by this class, notably the EGL context. This must be - * called from the thread where the context was created. - *

- * On completion, no context will be current. - */ - public void release() { - if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { - // Android is unusual in that it uses a reference-counted EGLDisplay. So for - // every eglInitialize() we need an eglTerminate(). - EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, - EGL14.EGL_NO_CONTEXT); - EGL14.eglDestroyContext(mEGLDisplay, mEGLContext); - EGL14.eglReleaseThread(); - EGL14.eglTerminate(mEGLDisplay); - } - - mEGLDisplay = EGL14.EGL_NO_DISPLAY; - mEGLContext = EGL14.EGL_NO_CONTEXT; - mEGLConfig = null; - } - - @Override - protected void finalize() throws Throwable { - try { - if (mEGLDisplay != EGL14.EGL_NO_DISPLAY) { - // We're limited here -- finalizers don't run on the thread that holds - // the EGL state, so if a surface or context is still current on another - // thread we can't fully release it here. Exceptions thrown from here - // are quietly discarded. Complain in the log file. - Log.w(TAG, "WARNING: EglCore was not explicitly released -- state may be leaked"); - release(); - } - } finally { - super.finalize(); - } - } - - /** - * Destroys the specified surface. Note the EGLSurface won't actually be destroyed if it's - * still current in a context. - */ - public void releaseSurface(EGLSurface eglSurface) { - EGL14.eglDestroySurface(mEGLDisplay, eglSurface); - } - - /** - * Creates an EGL surface associated with a Surface. - *

- * If this is destined for MediaCodec, the EGLConfig should have the "recordable" attribute. - */ - public EGLSurface createWindowSurface(Object surface) { - if (!(surface instanceof Surface) && !(surface instanceof SurfaceTexture)) { - throw new RuntimeException("invalid surface: " + surface); - } - - // Create a window surface, and attach it to the Surface we received. - int[] surfaceAttribs = { - EGL14.EGL_NONE - }; - EGLSurface eglSurface = EGL14.eglCreateWindowSurface(mEGLDisplay, mEGLConfig, surface, - surfaceAttribs, 0); - checkEglError("eglCreateWindowSurface"); - if (eglSurface == null) { - throw new RuntimeException("surface was null"); - } - return eglSurface; - } - - /** - * Creates an EGL surface associated with an offscreen buffer. - */ - public EGLSurface createOffscreenSurface(int width, int height) { - int[] surfaceAttribs = { - EGL14.EGL_WIDTH, width, - EGL14.EGL_HEIGHT, height, - EGL14.EGL_NONE - }; - EGLSurface eglSurface = EGL14.eglCreatePbufferSurface(mEGLDisplay, mEGLConfig, - surfaceAttribs, 0); - checkEglError("eglCreatePbufferSurface"); - if (eglSurface == null) { - throw new RuntimeException("surface was null"); - } - return eglSurface; - } - - /** - * Makes our EGL context current, using the supplied surface for both "draw" and "read". - */ - public void makeCurrent(EGLSurface eglSurface) { - if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { - // called makeCurrent() before create? - // Log.d(TAG, "NOTE: makeCurrent w/o display"); - } - if (!EGL14.eglMakeCurrent(mEGLDisplay, eglSurface, eglSurface, mEGLContext)) { - throw new RuntimeException("eglMakeCurrent failed"); - } - } - - /** - * Makes our EGL context current, using the supplied "draw" and "read" surfaces. - */ - public void makeCurrent(EGLSurface drawSurface, EGLSurface readSurface) { - if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { - // called makeCurrent() before create? - Log.d(TAG, "NOTE: makeCurrent w/o display"); - } - if (!EGL14.eglMakeCurrent(mEGLDisplay, drawSurface, readSurface, mEGLContext)) { - throw new RuntimeException("eglMakeCurrent(draw,read) failed"); - } - } - - /** - * Makes no context current. - */ - public void makeNothingCurrent() { - if (!EGL14.eglMakeCurrent(mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, - EGL14.EGL_NO_CONTEXT)) { - throw new RuntimeException("eglMakeCurrent failed"); - } - } - - /** - * Calls eglSwapBuffers. Use this to "publish" the current frame. - * - * @return false on failure - */ - public boolean swapBuffers(EGLSurface eglSurface) { - return EGL14.eglSwapBuffers(mEGLDisplay, eglSurface); - } - - /** - * Sends the presentation time stamp to EGL. Time is expressed in nanoseconds. - * https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt - */ - public void setPresentationTime(EGLSurface eglSurface, long nsecs) { - EGLExt.eglPresentationTimeANDROID(mEGLDisplay, eglSurface, nsecs); - } - - /** - * Returns true if our context and the specified surface are current. - */ - public boolean isCurrent(EGLSurface eglSurface) { - return mEGLContext.equals(EGL14.eglGetCurrentContext()) && - eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW)); - } - - /** - * Performs a simple surface query. - */ - public int querySurface(EGLSurface eglSurface, int what) { - int[] value = new int[1]; - EGL14.eglQuerySurface(mEGLDisplay, eglSurface, what, value, 0); - return value[0]; - } - - /** - * Queries a string value. - */ - public String queryString(int what) { - return EGL14.eglQueryString(mEGLDisplay, what); - } - - /** - * Returns the GLES version this context is configured for (currently 2 or 3). - */ - public int getGlVersion() { - return mGlVersion; - } - - /** - * Checks for EGL errors. Throws an exception if an error has been raised. - */ - private void checkEglError(String msg) { - int error; - if ((error = EGL14.eglGetError()) != EGL14.EGL_SUCCESS) { - throw new RuntimeException(msg + ": EGL error: 0x" + Integer.toHexString(error)); - } - } -} diff --git a/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.kt b/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.kt new file mode 100644 index 0000000..6fd5fe6 --- /dev/null +++ b/lassi/src/main/java/com/lassi/presentation/cameraview/video/EglCore.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2013 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.lassi.presentation.cameraview.video + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLExt +import android.opengl.EGLSurface +import android.os.Build +import android.util.Log +import android.view.Surface +import androidx.annotation.RequiresApi + +/** + * -- from grafika -- + * + * + * Core EGL state (display, context, config). + * + * + * The EGLContext must only be attached to one thread at a time. This class is not thread-safe. + */ +@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) +class EglCore @JvmOverloads constructor(sharedContext: EGLContext? = null, flags: Int = 0) { + private var mEGLDisplay: EGLDisplay? = EGL14.EGL_NO_DISPLAY + private var mEGLContext: EGLContext = EGL14.EGL_NO_CONTEXT + private var mEGLConfig: EGLConfig? = null + + /** + * Returns the GLES version this context is configured for (currently 2 or 3). + */ + var glVersion: Int = -1 + private set + + + /** + * Prepares EGL display and context. + * + * + * + * @param sharedContext The context to share, or null if sharing is not desired. + * @param flags Configuration bit flags, e.g. FLAG_RECORDABLE. + */ + /** + * Prepares EGL display and context. + * + * + * Equivalent to EglCore(null, 0). + */ + init { + var sharedContext = sharedContext + if (mEGLDisplay !== EGL14.EGL_NO_DISPLAY) { + throw RuntimeException("EGL already set up") + } + + if (sharedContext == null) { + sharedContext = EGL14.EGL_NO_CONTEXT + } + + mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (mEGLDisplay === EGL14.EGL_NO_DISPLAY) { + throw RuntimeException("unable to get EGL14 display") + } + val version = IntArray(2) + if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) { + mEGLDisplay = null + throw RuntimeException("unable to initialize EGL14") + } + + // Try to get a GLES3 context, if requested. + if ((flags and FLAG_TRY_GLES3) != 0) { + val config = getConfig(flags, 3) + if (config != null) { + val attrib3_list = intArrayOf( + EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, + EGL14.EGL_NONE + ) + val context = EGL14.eglCreateContext( + mEGLDisplay, config, sharedContext, + attrib3_list, 0 + ) + + if (EGL14.eglGetError() == EGL14.EGL_SUCCESS) { + mEGLConfig = config + mEGLContext = context + this.glVersion = 3 + } + } + } + if (mEGLContext === EGL14.EGL_NO_CONTEXT) { // GLES 2 only, or GLES 3 attempt failed + val config = + getConfig(flags, 2) ?: throw RuntimeException("Unable to find a suitable EGLConfig") + val attrib2_list = intArrayOf( + EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, + EGL14.EGL_NONE + ) + val context = EGL14.eglCreateContext( + mEGLDisplay, config, sharedContext, + attrib2_list, 0 + ) + checkEglError("eglCreateContext") + mEGLConfig = config + mEGLContext = context + this.glVersion = 2 + } + + // Confirm with query. + val values = IntArray(1) + EGL14.eglQueryContext( + mEGLDisplay, mEGLContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, + values, 0 + ) + } + + /** + * Finds a suitable EGLConfig. + * + * @param flags Bit flags from constructor. + * @param version Must be 2 or 3. + */ + private fun getConfig(flags: Int, version: Int): EGLConfig? { + var renderableType = EGL14.EGL_OPENGL_ES2_BIT + if (version >= 3) { + renderableType = renderableType or EGLExt.EGL_OPENGL_ES3_BIT_KHR + } + + // The actual surface is generally RGBA or RGBX, so situationally omitting alpha + // doesn't really help. It can also lead to a huge performance hit on glReadPixels() + // when reading into a GL_RGBA buffer. + val attribList = intArrayOf( + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, //EGL14.EGL_DEPTH_SIZE, 16, + //EGL14.EGL_STENCIL_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, renderableType, + EGL14.EGL_NONE, 0, // placeholder for recordable [@-3] + EGL14.EGL_NONE + ) + if ((flags and FLAG_RECORDABLE) != 0) { + attribList[attribList.size - 3] = EGL_RECORDABLE_ANDROID + attribList[attribList.size - 2] = 1 + } + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + if (!EGL14.eglChooseConfig( + mEGLDisplay, attribList, 0, configs, 0, configs.size, + numConfigs, 0 + ) + ) { + Log.w(TAG, "unable to find RGB8888 / $version EGLConfig") + return null + } + return configs[0] + } + + /** + * Discards all resources held by this class, notably the EGL context. This must be + * called from the thread where the context was created. + * + * + * On completion, no context will be current. + */ + fun release() { + if (mEGLDisplay !== EGL14.EGL_NO_DISPLAY) { + // Android is unusual in that it uses a reference-counted EGLDisplay. So for + // every eglInitialize() we need an eglTerminate(). + EGL14.eglMakeCurrent( + mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT + ) + EGL14.eglDestroyContext(mEGLDisplay, mEGLContext) + EGL14.eglReleaseThread() + EGL14.eglTerminate(mEGLDisplay) + } + + mEGLDisplay = EGL14.EGL_NO_DISPLAY + mEGLContext = EGL14.EGL_NO_CONTEXT + mEGLConfig = null + } + + @Throws(Throwable::class) + protected fun finalize() { + try { + if (mEGLDisplay !== EGL14.EGL_NO_DISPLAY) { + // We're limited here -- finalizers don't run on the thread that holds + // the EGL state, so if a surface or context is still current on another + // thread we can't fully release it here. Exceptions thrown from here + // are quietly discarded. Complain in the log file. + Log.w(TAG, "WARNING: EglCore was not explicitly released -- state may be leaked") + release() + } + } catch (e: Exception) { + e.message?.let { Log.e(TAG, it) } + } + } + + /** + * Destroys the specified surface. Note the EGLSurface won't actually be destroyed if it's + * still current in a context. + */ + fun releaseSurface(eglSurface: EGLSurface?) { + EGL14.eglDestroySurface(mEGLDisplay, eglSurface) + } + + /** + * Creates an EGL surface associated with a Surface. + * + * + * If this is destined for MediaCodec, the EGLConfig should have the "recordable" attribute. + */ + fun createWindowSurface(surface: Any?): EGLSurface { + if (surface !is Surface && surface !is SurfaceTexture) { + throw RuntimeException("invalid surface: " + surface) + } + + // Create a window surface, and attach it to the Surface we received. + val surfaceAttribs = intArrayOf( + EGL14.EGL_NONE + ) + val eglSurface = EGL14.eglCreateWindowSurface( + mEGLDisplay, mEGLConfig, surface, + surfaceAttribs, 0 + ) + checkEglError("eglCreateWindowSurface") + if (eglSurface == null) { + throw RuntimeException("surface was null") + } + return eglSurface + } + + /** + * Creates an EGL surface associated with an offscreen buffer. + */ + fun createOffscreenSurface(width: Int, height: Int): EGLSurface { + val surfaceAttribs = intArrayOf( + EGL14.EGL_WIDTH, width, + EGL14.EGL_HEIGHT, height, + EGL14.EGL_NONE + ) + val eglSurface = EGL14.eglCreatePbufferSurface( + mEGLDisplay, mEGLConfig, + surfaceAttribs, 0 + ) + checkEglError("eglCreatePbufferSurface") + if (eglSurface == null) { + throw RuntimeException("surface was null") + } + return eglSurface + } + + /** + * Makes our EGL context current, using the supplied surface for both "draw" and "read". + */ + fun makeCurrent(eglSurface: EGLSurface?) { + if (!EGL14.eglMakeCurrent(mEGLDisplay, eglSurface, eglSurface, mEGLContext)) { + throw RuntimeException("eglMakeCurrent failed") + } + } + + /** + * Makes our EGL context current, using the supplied "draw" and "read" surfaces. + */ + fun makeCurrent(drawSurface: EGLSurface?, readSurface: EGLSurface?) { + if (mEGLDisplay === EGL14.EGL_NO_DISPLAY) { + // called makeCurrent() before create? + Log.d(TAG, "NOTE: makeCurrent w/o display") + } + if (!EGL14.eglMakeCurrent(mEGLDisplay, drawSurface, readSurface, mEGLContext)) { + throw RuntimeException("eglMakeCurrent(draw,read) failed") + } + } + + /** + * Makes no context current. + */ + fun makeNothingCurrent() { + if (!EGL14.eglMakeCurrent( + mEGLDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT + ) + ) { + throw RuntimeException("eglMakeCurrent failed") + } + } + + /** + * Calls eglSwapBuffers. Use this to "publish" the current frame. + * + * @return false on failure + */ + fun swapBuffers(eglSurface: EGLSurface?): Boolean { + return EGL14.eglSwapBuffers(mEGLDisplay, eglSurface) + } + + /** + * Sends the presentation time stamp to EGL. Time is expressed in nanoseconds. + * https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt + */ + fun setPresentationTime(eglSurface: EGLSurface?, nsecs: Long) { + EGLExt.eglPresentationTimeANDROID(mEGLDisplay, eglSurface, nsecs) + } + + /** + * Returns true if our context and the specified surface are current. + */ + fun isCurrent(eglSurface: EGLSurface): Boolean { + return mEGLContext == EGL14.eglGetCurrentContext() && + eglSurface == EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW) + } + + /** + * Performs a simple surface query. + */ + fun querySurface(eglSurface: EGLSurface?, what: Int): Int { + val value = IntArray(1) + EGL14.eglQuerySurface(mEGLDisplay, eglSurface, what, value, 0) + return value[0] + } + + /** + * Queries a string value. + */ + fun queryString(what: Int): String? { + return EGL14.eglQueryString(mEGLDisplay, what) + } + + /** + * Checks for EGL errors. Throws an exception if an error has been raised. + */ + private fun checkEglError(msg: String?) { + val error: Int + if ((EGL14.eglGetError().also { error = it }) != EGL14.EGL_SUCCESS) { + throw RuntimeException(msg + ": EGL error: 0x" + Integer.toHexString(error)) + } + } + + companion object { + /** + * Constructor flag: surface must be recordable. This discourages EGL from using a + * pixel format that cannot be converted efficiently to something usable by the video + * encoder. + */ + const val FLAG_RECORDABLE: Int = 0x01 + + /** + * Constructor flag: ask for GLES3, fall back to GLES2 if not available. Without this + * flag, GLES2 is used. + */ + const val FLAG_TRY_GLES3: Int = 0x02 + private val TAG: String = EglCore::class.java.getSimpleName() + + // Android-specific extension. + private const val EGL_RECORDABLE_ANDROID = 0x3142 + } +} diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/BitmapUtils.kt b/lassi/src/main/java/com/lassi/presentation/cropper/BitmapUtils.kt index 32db105..6223403 100644 --- a/lassi/src/main/java/com/lassi/presentation/cropper/BitmapUtils.kt +++ b/lassi/src/main/java/com/lassi/presentation/cropper/BitmapUtils.kt @@ -30,6 +30,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sin +import androidx.core.graphics.scale /** * Utility class that deals with operations with an ImageView. @@ -526,12 +527,8 @@ internal object BitmapUtils { 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, - ) + resized = + bitmap.scale((width / scale).toInt(), (height / scale).toInt(), false) } } if (resized != null) { diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/CropImageActivity.kt b/lassi/src/main/java/com/lassi/presentation/cropper/CropImageActivity.kt index 2a4ca78..2c51e8e 100644 --- a/lassi/src/main/java/com/lassi/presentation/cropper/CropImageActivity.kt +++ b/lassi/src/main/java/com/lassi/presentation/cropper/CropImageActivity.kt @@ -2,6 +2,7 @@ package com.lassi.presentation.cropper import android.app.Activity import android.content.Intent +import android.graphics.Matrix import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle @@ -22,6 +23,7 @@ import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface import com.lassi.R import com.lassi.common.extenstions.applyBottomInset import com.lassi.common.extenstions.applyEdgeToEdge @@ -36,13 +38,13 @@ import com.lassi.domain.media.LassiConfig import com.lassi.domain.media.MultiLangConfig import com.lassi.presentation.cropper.utils.getUriForFile import java.io.File +import java.io.IOException open class CropImageActivity : AppCompatActivity(), CropImageView.OnSetImageUriCompleteListener, CropImageView.OnCropImageCompleteListener { private val logTag = CropImageActivity::class.java.simpleName - /** - * The crop image view library widget used in the activity - */ + + var isAutoZoomed: Boolean = false /** Persist URI image to crop URI if specific permissions are required. */ private var cropImageUri: Uri? = null @@ -438,7 +440,7 @@ open class CropImageActivity : AppCompatActivity(), CropImageView.OnSetImageUriC val intent = Intent().apply { putExtra(KeyUtils.MEDIA_PREVIEW, miMedia) } - setResult(Activity.RESULT_OK, intent) + setResult(RESULT_OK, intent) finish() } catch (e: Exception) { Logger.e(logTag, "onFileScanComplete $e") @@ -452,7 +454,7 @@ open class CropImageActivity : AppCompatActivity(), CropImageView.OnSetImageUriC val intent = Intent().apply { putExtra(KeyUtils.MEDIA_PREVIEW, miMedia) } - setResult(Activity.RESULT_OK, intent) + setResult(RESULT_OK, intent) finish() } } diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/CropImageView.kt b/lassi/src/main/java/com/lassi/presentation/cropper/CropImageView.kt index e8273ac..55c93bf 100644 --- a/lassi/src/main/java/com/lassi/presentation/cropper/CropImageView.kt +++ b/lassi/src/main/java/com/lassi/presentation/cropper/CropImageView.kt @@ -12,19 +12,18 @@ import android.graphics.Matrix import android.graphics.Rect import android.graphics.RectF import android.net.Uri -import android.os.Build import android.os.Build.VERSION.SDK_INT import android.os.Bundle import android.os.Parcelable import android.provider.MediaStore import android.util.AttributeSet +import android.util.Log import android.util.Pair import android.util.Size import android.view.LayoutInflater import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar -import androidx.annotation.RequiresApi import androidx.core.util.component1 import androidx.core.util.component2 import androidx.core.view.ViewCompat @@ -32,9 +31,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.exifinterface.media.ExifInterface import com.lassi.R -import com.lassi.presentation.cropper.CropOverlayView import com.lassi.presentation.cropper.CropOverlayView.CropWindowChangeListener -import com.lassi.presentation.cropper.* import com.lassi.presentation.cropper.utils.getFilePathFromUri import java.lang.ref.WeakReference import java.util.UUID @@ -44,1852 +41,2085 @@ import kotlin.math.pow import kotlin.math.sqrt /** Custom view that provides cropping capabilities to an image. */ -@RequiresApi(Build.VERSION_CODES.LOLLIPOP) class CropImageView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, + context: Context, + attrs: AttributeSet? = null, ) : FrameLayout(context, attrs), CropWindowChangeListener { - /** Image view widget used to show the image for cropping. */ - private val imageView: ImageView - - /** Overlay over the image view to show cropping UI. */ - private val mCropOverlayView: CropOverlayView? + /** Image view widget used to show the image for cropping. */ + private lateinit var _imageView: TouchImageView - /** The matrix used to transform the cropping image in the image view */ - private val mImageMatrix = Matrix() + var imageView: TouchImageView + get() = _imageView + set(value) { + _imageView = value + } - /** Reusing matrix instance for reverse matrix calculations. */ - private val mImageInverseMatrix = Matrix() - - /** Progress bar widget to show progress bar on async image loading and cropping. */ - private val mProgressBar: ProgressBar - - /** Rectangle used in image matrix transformation calculation (reusing rect instance) */ - private val mImagePoints = FloatArray(8) - - /** Rectangle used in image matrix transformation for scale calculation (reusing rect instance) */ - private val mScaleImagePoints = FloatArray(8) - - /** Animation class to smooth animate zoom-in/out */ - private var mAnimation: CropImageAnimation? = null - private var originalBitmap: Bitmap? = null + /** Overlay over the image view to show cropping UI. */ + private val mCropOverlayView: CropOverlayView? - /** The image rotation value used during loading of the image, so we can reset to it */ - private var mInitialDegreesRotated = 0 + /** The matrix used to transform the cropping image in the image view */ + val mImageMatrix = Matrix() - /** How much the image is rotated from original clockwise */ - private var mDegreesRotated = 0 + /** Reusing matrix instance for reverse matrix calculations. */ + private val mImageInverseMatrix = Matrix() - /** If the image flipped horizontally */ - private var mFlipHorizontally: Boolean + /** Progress bar widget to show progress bar on async image loading and cropping. */ + private val mProgressBar: ProgressBar - /** If the image flipped vertically */ - private var mFlipVertically: Boolean - private var mLayoutWidth = 0 - private var mLayoutHeight = 0 - private var mImageResource = 0 + /** Rectangle used in image matrix transformation calculation (reusing rect instance) */ + private val mImagePoints = FloatArray(8) - /** The initial scale type of the image in the crop image view */ - private var mScaleType: ScaleType + /** Rectangle used in image matrix transformation for scale calculation (reusing rect instance) */ + private val mScaleImagePoints = FloatArray(8) - /** - * if to save bitmap on save instance state.

- * It is best to avoid it by using URI in setting image for cropping.

- * If false the bitmap is not saved and if restore is required to view will be empty, storing the - * bitmap requires saving it to file which can be expensive. default: false. - */ - @Deprecated("This functionality is deprecated, please remove it altogether or create an issue and explain WHY you need this.") - var isSaveBitmapToInstanceState = false + /** Animation class to smooth animate zoom-in/out */ + private var mAnimation: CropImageAnimation? = null + private var originalBitmap: Bitmap? = null - /** - * if to show crop overlay UI what contains the crop window UI surrounded by background over the - * cropping image.

- * default: true, may disable for animation or frame transition. - */ - private var mShowCropOverlay = true + /** The image rotation value used during loading of the image, so we can reset to it */ + private var mInitialDegreesRotated = 0 - /** If true, shows a helper text label over crop overlay UI - * default: false - */ - private var mShowCropLabel = false + /** How much the image is rotated from original clockwise */ + private var mDegreesRotated = 0 - /** - * Helper text label over crop overlay UI - * default: empty string - */ - private var mCropTextLabel = "" + /** If the image flipped horizontally */ + private var mFlipHorizontally: Boolean - /** - * Text size for text label over crop overlay UI - * default: 20sp - */ - private var mCropLabelTextSize = 20f + /** If the image flipped vertically */ + private var mFlipVertically: Boolean + private var mLayoutWidth = 0 + private var mLayoutHeight = 0 + private var mImageResource = 0 - /** - * Text color for text label over crop overlay UI - * default: White - */ - private var mCropLabelTextColor = Color.WHITE + /** The initial scale type of the image in the crop image view */ + private var mScaleType: ScaleType - /** - * if to show progress bar when image async loading/cropping is in progress.

- * default: true, disable to provide custom progress bar UI. - */ - private var mShowProgressBar = true + /** + * if to save bitmap on save instance state.

+ * It is best to avoid it by using URI in setting image for cropping.

+ * If false the bitmap is not saved and if restore is required to view will be empty, storing the + * bitmap requires saving it to file which can be expensive. default: false. + */ + @Deprecated("This functionality is deprecated, please remove it altogether or create an issue and explain WHY you need this.") + var isSaveBitmapToInstanceState = false - /** - * if auto-zoom functionality is enabled.

- * default: true. - */ - private var mAutoZoomEnabled = true + /** + * if to show crop overlay UI what contains the crop window UI surrounded by background over the + * cropping image.

+ * default: true, may disable for animation or frame transition. + */ + private var mShowCropOverlay = true - /** The max zoom allowed during cropping */ - private var mMaxZoom: Int + /** If true, shows a helper text label over crop overlay UI + * default: false + */ + private var mShowCropLabel = false - /** Callback to be invoked when crop overlay is released. */ - private var mOnCropOverlayReleasedListener: OnSetCropOverlayReleasedListener? = null + /** + * Helper text label over crop overlay UI + * default: empty string + */ + private var mCropTextLabel = "" - /** Callback to be invoked when crop overlay is moved. */ - private var mOnSetCropOverlayMovedListener: OnSetCropOverlayMovedListener? = null + /** + * Text size for text label over crop overlay UI + * default: 20sp + */ + private var mCropLabelTextSize = 20f - /** Callback to be invoked when crop window is changed. */ - private var mOnSetCropWindowChangeListener: OnSetCropWindowChangeListener? = null - - /** Callback to be invoked when image async loading is complete. */ - private var mOnSetImageUriCompleteListener: OnSetImageUriCompleteListener? = null - - /** Callback to be invoked when image async cropping is complete. */ - private var mOnCropImageCompleteListener: OnCropImageCompleteListener? = null - /** Get the URI of an image that was set by URI, null otherwise. */ - /** The URI that the image was loaded from (if loaded from URI) */ - var imageUri: Uri? = null - private set - - /** The sample size the image was loaded by if was loaded by URI */ - private var loadedSampleSize = 1 - - /** The current zoom level to scale the cropping image */ - private var mZoom = 1f - - /** The X offset that the cropping image was translated after zooming */ - private var mZoomOffsetX = 0f - - /** The Y offset that the cropping image was translated after zooming */ - private var mZoomOffsetY = 0f - - /** Used to restore the cropping windows rectangle after state restore */ - private var mRestoreCropWindowRect: RectF? = null - - /** Used to restore image rotation after state restore */ - private var mRestoreDegreesRotated = 0 - - /** Used to detect size change to handle auto-zoom using [handleCropWindowChanged] in layout. */ - private var mSizeChanged = false - - /** Task used to load bitmap async from UI thread */ - private var bitmapLoadingWorkerJob: WeakReference? = null - - /** Task used to crop bitmap async from UI thread */ - private var bitmapCroppingWorkerJob: WeakReference? = null - - /** Get / set the scale type of the image in the crop view. */ - var scaleType: ScaleType - get() = mScaleType - set(scaleType) { - if (scaleType != mScaleType) { - mScaleType = scaleType - mZoom = 1f - mZoomOffsetY = 0f - mZoomOffsetX = mZoomOffsetY - mCropOverlayView?.resetCropOverlayView() - requestLayout() - } - } + /** + * Text color for text label over crop overlay UI + * default: White + */ + private var mCropLabelTextColor = Color.WHITE - /** - * The shape of the cropping area - rectangle/circular.

- * To set square/circle crop shape set aspect ratio to 1:1. - * - * When setting RECTANGLE_VERTICAL_ONLY or RECTANGLE_HORIZONTAL_ONLY you may also want to - * use a free aspect ratio (to allow the crop window to change in the desired dimension - * whilst staying the same in the other dimension) and have the crop window start covering the - * entirety of the image (so that the crop window has no space to move in the other dimension). - * These can be done with - * [CropImageView.setFixedAspectRatio] } (with argument `false`) and - * [CropImageView.wholeImageRect] } (with argument `cropImageView.getWholeImageRect()`). - */ - var cropShape: CropShape? - get() = mCropOverlayView!!.cropShape - set(cropShape) { - mCropOverlayView!!.setCropShape(cropShape!!) - } + /** + * if to show progress bar when image async loading/cropping is in progress.

+ * default: true, disable to provide custom progress bar UI. + */ + private var mShowProgressBar = true - /** - * The shape of the crop corner in the crop overlay (Rectangular / Circular) - */ - var cornerShape: CropCornerShape? - get() = mCropOverlayView!!.cornerShape - set(cornerShape) { - mCropOverlayView!!.setCropCornerShape(cornerShape!!) - } + /** + * if auto-zoom functionality is enabled.

+ * default: true. + */ + private var mAutoZoomEnabled = true - /** Set auto-zoom functionality to enabled/disabled. */ - var isAutoZoomEnabled: Boolean - get() = mAutoZoomEnabled - set(autoZoomEnabled) { - if (mAutoZoomEnabled != autoZoomEnabled) { - mAutoZoomEnabled = autoZoomEnabled - handleCropWindowChanged(inProgress = false, animate = false) - mCropOverlayView!!.invalidate() - } - } + /** The max zoom allowed during cropping */ + private var mMaxZoom: Int - /** Set multitouch functionality to enabled/disabled. */ - fun setMultiTouchEnabled(multiTouchEnabled: Boolean) { - if (mCropOverlayView!!.setMultiTouchEnabled(multiTouchEnabled)) { - handleCropWindowChanged(inProgress = false, animate = false) - mCropOverlayView.invalidate() - } - } + /** Callback to be invoked when crop overlay is released. */ + private var mOnCropOverlayReleasedListener: OnSetCropOverlayReleasedListener? = null - /** Set moving of the crop window by dragging the center to enabled/disabled. */ - fun setCenterMoveEnabled(centerMoveEnabled: Boolean) { - if (mCropOverlayView!!.setCenterMoveEnabled(centerMoveEnabled)) { - handleCropWindowChanged(inProgress = false, animate = false) - mCropOverlayView.invalidate() - } - } + /** Callback to be invoked when crop overlay is moved. */ + private var mOnSetCropOverlayMovedListener: OnSetCropOverlayMovedListener? = null - /** The max zoom allowed during cropping. */ - var maxZoom: Int - get() = mMaxZoom - set(maxZoom) { - if (mMaxZoom != maxZoom && maxZoom > 0) { - mMaxZoom = maxZoom - handleCropWindowChanged(inProgress = false, animate = false) - mCropOverlayView!!.invalidate() - } - } + /** Callback to be invoked when crop window is changed. */ + private var mOnSetCropWindowChangeListener: OnSetCropWindowChangeListener? = null - /** - * the min size the resulting cropping image is allowed to be, affects the cropping window limits - * (in pixels).

- */ - fun setMinCropResultSize(minCropResultWidth: Int, minCropResultHeight: Int) { - mCropOverlayView!!.setMinCropResultSize(minCropResultWidth, minCropResultHeight) - } + /** Callback to be invoked when image async loading is complete. */ + private var mOnSetImageUriCompleteListener: OnSetImageUriCompleteListener? = null - /** - * the max size the resulting cropping image is allowed to be, affects the cropping window limits - * (in pixels).

- */ - fun setMaxCropResultSize(maxCropResultWidth: Int, maxCropResultHeight: Int) { - mCropOverlayView!!.setMaxCropResultSize(maxCropResultWidth, maxCropResultHeight) - } + /** Callback to be invoked when image async cropping is complete. */ + private var mOnCropImageCompleteListener: OnCropImageCompleteListener? = null + /** Get the URI of an image that was set by URI, null otherwise. */ + /** The URI that the image was loaded from (if loaded from URI) */ + var imageUri: Uri? = null + private set - /** - * Set / Get the amount of degrees (between 0 and 360) the cropping image is rotated clockwise.

- */ - var rotatedDegrees: Int - get() = mDegreesRotated - set(degrees) { - if (mDegreesRotated != degrees) { - rotateImage(degrees - mDegreesRotated) - } - } + /** The sample size the image was loaded by if was loaded by URI */ + private var loadedSampleSize = 1 - /** - * whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows it to - * be changed. - */ - val isFixAspectRatio: Boolean - get() = mCropOverlayView!!.isFixAspectRatio - - /** - * Sets whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows - * it to be changed. - */ - fun setFixedAspectRatio(fixAspectRatio: Boolean) { - mCropOverlayView!!.setFixedAspectRatio(fixAspectRatio) - } + /** The current zoom level to scale the cropping image */ + private var mZoom = 1f - /** Sets whether the image should be flipped horizontally */ - var isFlippedHorizontally: Boolean - get() = mFlipHorizontally - set(flipHorizontally) { - if (mFlipHorizontally != flipHorizontally) { - mFlipHorizontally = flipHorizontally - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - } - } + /** The X offset that the cropping image was translated after zooming */ + private var mZoomOffsetX = 0f - /** The Android Uri to save the cropped image to */ - var customOutputUri: Uri? = null + /** The Y offset that the cropping image was translated after zooming */ + private var mZoomOffsetY = 0f - /** Sets whether the image should be flipped vertically */ - var isFlippedVertically: Boolean - get() = mFlipVertically - set(flipVertically) { - if (mFlipVertically != flipVertically) { - mFlipVertically = flipVertically - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - } - } - /** Get the current guidelines option set. */ - /** - * Sets the guidelines for the CropOverlayView to be either on, off, or to show when resizing the - * application. - */ - var guidelines: Guidelines? - get() = mCropOverlayView!!.guidelines - set(guidelines) { - mCropOverlayView!!.setGuidelines(guidelines!!) - } + /** Used to restore the cropping windows rectangle after state restore */ + private var mRestoreCropWindowRect: RectF? = null - /** Both the X and Y values of the aspectRatio. */ - val aspectRatio: Pair - get() = Pair(mCropOverlayView!!.aspectRatioX, mCropOverlayView.aspectRatioY) - - /** - * Sets the both the X and Y values of the aspectRatio.

- * Sets fixed aspect ratio to TRUE. - * - * [aspectRatioX] int that specifies the new X value of the aspect ratio - * [aspectRatioY] int that specifies the new Y value of the aspect ratio - */ - fun setAspectRatio(aspectRatioX: Int, aspectRatioY: Int) { - mCropOverlayView!!.aspectRatioX = aspectRatioX - mCropOverlayView.aspectRatioY = aspectRatioY - mCropOverlayView.setFixedAspectRatio(true) - } + /** Used to restore image rotation after state restore */ + private var mRestoreDegreesRotated = 0 - fun setImageCropOptions(options: CropImageOptions) { - scaleType = options.scaleType - customOutputUri = options.customOutputUri - mCropOverlayView?.setInitialAttributeValues(options) - setMultiTouchEnabled(options.multiTouchEnabled) - setCenterMoveEnabled(options.centerMoveEnabled) - isShowCropOverlay = options.showCropOverlay - isShowProgressBar = options.showProgressBar - isAutoZoomEnabled = options.autoZoomEnabled - maxZoom = options.maxZoom - isFlippedHorizontally = options.flipHorizontally - isFlippedVertically = options.flipVertically - mAutoZoomEnabled = options.autoZoomEnabled - mShowCropOverlay = options.showCropOverlay - mShowProgressBar = options.showProgressBar - mProgressBar.indeterminateTintList = ColorStateList.valueOf(options.progressBarColor) - } + /** Used to detect size change to handle auto-zoom using [handleCropWindowChanged] in layout. */ + private var mSizeChanged = false - /** Clears set aspect ratio values and sets fixed aspect ratio to FALSE. */ - fun clearAspectRatio() { - mCropOverlayView!!.aspectRatioX = 1 - mCropOverlayView.aspectRatioY = 1 - setFixedAspectRatio(false) - } + /** Task used to load bitmap async from UI thread */ + private var bitmapLoadingWorkerJob: WeakReference? = null - /** - * 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. (default: 3dp) - */ - fun setSnapRadius(snapRadius: Float) { - if (snapRadius >= 0) mCropOverlayView!!.setSnapRadius(snapRadius) - } + /** Task used to crop bitmap async from UI thread */ + private var bitmapCroppingWorkerJob: WeakReference? = null - /** - * if to show progress bar when image async loading/cropping is in progress.

- * default: true, disable to provide custom progress bar UI. - */ - var isShowProgressBar: Boolean - get() = mShowProgressBar - set(showProgressBar) { - if (mShowProgressBar != showProgressBar) { - mShowProgressBar = showProgressBar - setProgressBarVisibility() - } - } + var isManualMode: Boolean = false + var isMatrixSynced = false + var lastManualEndTime = 0L - /** - * if to show crop overlay UI what contains the crop window UI surrounded by background over the - * cropping image.

- * default: true, may disable for animation or frame transition. - */ - var isShowCropOverlay: Boolean - get() = mShowCropOverlay - set(showCropOverlay) { - if (mShowCropOverlay != showCropOverlay) { - mShowCropOverlay = showCropOverlay - setCropOverlayVisibility() - } - } + /** Get / set the scale type of the image in the crop view. */ + private var scaleType: ScaleType + get() = mScaleType + set(scaleType) { + if (scaleType != mScaleType) { + mScaleType = scaleType + mZoom = 1f + mZoomOffsetY = 0f + mZoomOffsetX = mZoomOffsetY + mCropOverlayView?.resetCropOverlayView() + requestLayout() + } + } - /** - * If enabled, show a text label on top of crop overlay UI, which gets moved along with the cropper - */ - var isShowCropLabel: Boolean - get() = mShowCropLabel - set(showCropLabel) { - if (mShowCropLabel != showCropLabel) { - mShowCropLabel = showCropLabel - mCropOverlayView?.setCropperTextLabelVisibility(mShowCropLabel) - } - } - var cropLabelText: String - get() = mCropTextLabel - set(cropLabelText) { - mCropTextLabel = cropLabelText - mCropOverlayView?.setCropLabelText(cropLabelText) - } - var cropLabelTextSize: Float - get() = mCropLabelTextSize - set(textSize) { - mCropLabelTextSize = cropLabelTextSize - mCropOverlayView?.setCropLabelTextSize(textSize) - } - var cropLabelTextColor: Int - get() = mCropLabelTextColor - set(cropLabelTextColor) { - mCropLabelTextColor = cropLabelTextColor - mCropOverlayView?.setCropLabelTextColor(cropLabelTextColor) + /** + * The shape of the cropping area - rectangle/circular.

+ * To set square/circle crop shape set aspect ratio to 1:1. + * + * When setting RECTANGLE_VERTICAL_ONLY or RECTANGLE_HORIZONTAL_ONLY you may also want to + * use a free aspect ratio (to allow the crop window to change in the desired dimension + * whilst staying the same in the other dimension) and have the crop window start covering the + * entirety of the image (so that the crop window has no space to move in the other dimension). + * These can be done with + * [CropImageView.setFixedAspectRatio] } (with argument `false`) and + * [CropImageView.wholeImageRect] } (with argument `cropImageView.getWholeImageRect()`). + */ + var cropShape: CropShape? + get() = mCropOverlayView!!.cropShape + set(cropShape) { + mCropOverlayView!!.setCropShape(cropShape!!) + } + + /** + * The shape of the crop corner in the crop overlay (Rectangular / Circular) + */ + var cornerShape: CropCornerShape? + get() = mCropOverlayView!!.cornerShape + set(cornerShape) { + mCropOverlayView!!.setCropCornerShape(cornerShape!!) + } + + /** Set auto-zoom functionality to enabled/disabled. */ + private var isAutoZoomEnabled: Boolean + get() = mAutoZoomEnabled + set(autoZoomEnabled) { + if (mAutoZoomEnabled != autoZoomEnabled) { + mAutoZoomEnabled = autoZoomEnabled + handleCropWindowChanged(inProgress = false, animate = false) + mCropOverlayView!!.invalidate() + } + } + + /** Set multitouch functionality to enabled/disabled. */ + private fun setMultiTouchEnabled(multiTouchEnabled: Boolean) { + if (mCropOverlayView!!.setMultiTouchEnabled(multiTouchEnabled)) { + handleCropWindowChanged(inProgress = false, animate = false) + mCropOverlayView.invalidate() + } } - /** Returns the integer of the imageResource */ - /** - * Sets a Drawable as the content of the CropImageView. - * - * resId the drawable resource ID to set - */ - var imageResource: Int - get() = mImageResource - set(resId) { - if (resId != 0) { - mCropOverlayView!!.initialCropWindowRect = null - val bitmap = BitmapFactory.decodeResource(resources, resId) - setBitmap( - bitmap = bitmap, - imageResource = resId, - imageUri = null, - loadSampleSize = 1, - degreesRotated = 0, - ) - } + + /** Set moving of the crop window by dragging the center to enabled/disabled. */ + private fun setCenterMoveEnabled(centerMoveEnabled: Boolean) { + if (mCropOverlayView!!.setCenterMoveEnabled(centerMoveEnabled)) { + handleCropWindowChanged(inProgress = false, animate = false) + mCropOverlayView.invalidate() + } } - /** - * Gets the source Bitmap's dimensions. This represents the largest possible crop rectangle. - * - * @return a Rect instance dimensions of the source Bitmap - */ - val wholeImageRect: Rect? - get() { - val loadedSampleSize = loadedSampleSize - val bitmap = originalBitmap ?: return null - val orgWidth = bitmap.width * loadedSampleSize - val orgHeight = bitmap.height * loadedSampleSize - return Rect(0, 0, orgWidth, orgHeight) + /** The max zoom allowed during cropping. */ + private var maxZoom: Int + get() = mMaxZoom + set(maxZoom) { + if (mMaxZoom != maxZoom && maxZoom > 0) { + mMaxZoom = maxZoom + handleCropWindowChanged(inProgress = false, animate = false) + mCropOverlayView!!.invalidate() + } + } + + /** + * the min size the resulting cropping image is allowed to be, affects the cropping window limits + * (in pixels).

+ */ + fun setMinCropResultSize(minCropResultWidth: Int, minCropResultHeight: Int) { + mCropOverlayView!!.setMinCropResultSize(minCropResultWidth, minCropResultHeight) } - /** - * Gets the crop window's position relative to the source Bitmap (not the image displayed in the - * CropImageView) using the original image rotation. - * - * @return a Rect instance containing cropped area boundaries of the source Bitmap - * - * Set the crop window position and size to the given rectangle.

- * Image to crop must be first set before invoking this, for async - after complete callback. - * - * rect window rectangle (position and size) relative to source bitmap - */ - var cropRect: Rect? - get() { - val loadedSampleSize = loadedSampleSize - val bitmap = originalBitmap ?: return null - // get the points of the crop rectangle adjusted to source bitmap - val points = cropPoints - val orgWidth = bitmap.width * loadedSampleSize - val orgHeight = bitmap.height * loadedSampleSize - // get the rectangle for the points (it may be larger than original if rotation is not straight) - return BitmapUtils.getRectFromPoints( - cropPoints = points, - imageWidth = orgWidth, - imageHeight = orgHeight, - fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, - aspectRatioX = mCropOverlayView.aspectRatioX, - aspectRatioY = mCropOverlayView.aspectRatioY, - ) + /** + * the max size the resulting cropping image is allowed to be, affects the cropping window limits + * (in pixels).

+ */ + fun setMaxCropResultSize(maxCropResultWidth: Int, maxCropResultHeight: Int) { + mCropOverlayView!!.setMaxCropResultSize(maxCropResultWidth, maxCropResultHeight) } - set(rect) { - mCropOverlayView!!.initialCropWindowRect = rect + + /** + * Set / Get the amount of degrees (between 0 and 360) the cropping image is rotated clockwise.

+ */ + var rotatedDegrees: Int + get() = mDegreesRotated + set(degrees) { + if (mDegreesRotated != degrees) { + rotateImage(degrees - mDegreesRotated) + } + } + + /** + * whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows it to + * be changed. + */ + val isFixAspectRatio: Boolean + get() = mCropOverlayView!!.isFixAspectRatio + + /** + * Sets whether the aspect ratio is fixed or not; true fixes the aspect ratio, while false allows + * it to be changed. + */ + private fun setFixedAspectRatio(fixAspectRatio: Boolean) { + mCropOverlayView!!.setFixedAspectRatio(fixAspectRatio) } - /** This returns the expected image size, if cropping the image right now. */ - fun expectedImageSize(): Size? { - val rect = cropRect ?: return null + /** Sets whether the image should be flipped horizontally */ + private var isFlippedHorizontally: Boolean + get() = mFlipHorizontally + set(flipHorizontally) { + if (mFlipHorizontally != flipHorizontally) { + mFlipHorizontally = flipHorizontally + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, + ) + } + } + + /** The Android Uri to save the cropped image to */ + private var customOutputUri: Uri? = null + + /** Sets whether the image should be flipped vertically */ + private var isFlippedVertically: Boolean + get() = mFlipVertically + set(flipVertically) { + if (mFlipVertically != flipVertically) { + mFlipVertically = flipVertically + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, + ) + } + } + /** Get the current guidelines option set. */ + /** + * Sets the guidelines for the CropOverlayView to be either on, off, or to show when resizing the + * application. + */ + var guidelines: Guidelines? + get() = mCropOverlayView!!.guidelines + set(guidelines) { + mCropOverlayView!!.setGuidelines(guidelines!!) + } + + /** Both the X and Y values of the aspectRatio. */ + val aspectRatio: Pair + get() = Pair(mCropOverlayView!!.aspectRatioX, mCropOverlayView.aspectRatioY) - return if (rotatedDegrees == 0 || rotatedDegrees == 180) { - Size(rect.width(), rect.height()) - } else { - Size(rect.height(), rect.width()) + /** + * Sets the both the X and Y values of the aspectRatio.

+ * Sets fixed aspect ratio to TRUE. + * + * [aspectRatioX] int that specifies the new X value of the aspect ratio + * [aspectRatioY] int that specifies the new Y value of the aspect ratio + */ + fun setAspectRatio(aspectRatioX: Int, aspectRatioY: Int) { + mCropOverlayView!!.aspectRatioX = aspectRatioX + mCropOverlayView.aspectRatioY = aspectRatioY + mCropOverlayView.setFixedAspectRatio(true) } - } - /** - * Gets the crop window's position relative to the parent's view at screen. - * - * @return a Rect instance containing cropped area boundaries of the source Bitmap - */ - val cropWindowRect: RectF? - get() = mCropOverlayView?.cropWindowRect // Get crop window position relative to the displayed image. - - /** - * Gets the 4 points of crop window's position relative to the source Bitmap (not the image - * displayed in the CropImageView) using the original image rotation.

- * Note: the 4 points may not be a rectangle if the image was rotates to NOT straight angle (!= - * 90/180/270). - * - * @return 4 points (x0,y0,x1,y1,x2,y2,x3,y3) of cropped area boundaries - */ - val cropPoints: FloatArray - get() { - // Get crop window position relative to the displayed image. - val cropWindowRect = mCropOverlayView!!.cropWindowRect - val points = floatArrayOf( - cropWindowRect.left, - cropWindowRect.top, - cropWindowRect.right, - cropWindowRect.top, - cropWindowRect.right, - cropWindowRect.bottom, - cropWindowRect.left, - cropWindowRect.bottom, - ) - mImageMatrix.invert(mImageInverseMatrix) - mImageInverseMatrix.mapPoints(points) - val resultPoints = FloatArray(points.size) - for (i in points.indices) { - resultPoints[i] = points[i] * loadedSampleSize - } - return resultPoints + fun setImageCropOptions(options: CropImageOptions) { + scaleType = options.scaleType + customOutputUri = options.customOutputUri + mCropOverlayView?.setInitialAttributeValues(options) + setMultiTouchEnabled(options.multiTouchEnabled) + setCenterMoveEnabled(options.centerMoveEnabled) + isShowCropOverlay = options.showCropOverlay + isShowProgressBar = options.showProgressBar + isAutoZoomEnabled = options.autoZoomEnabled + maxZoom = options.maxZoom + isFlippedHorizontally = options.flipHorizontally + isFlippedVertically = options.flipVertically + mAutoZoomEnabled = options.autoZoomEnabled + mShowCropOverlay = options.showCropOverlay + mShowProgressBar = options.showProgressBar + mProgressBar.indeterminateTintList = ColorStateList.valueOf(options.progressBarColor) } - /** Reset crop window to initial rectangle. */ - fun resetCropRect() { - mZoom = 1f - mZoomOffsetX = 0f - mZoomOffsetY = 0f - mDegreesRotated = mInitialDegreesRotated - mFlipHorizontally = false - mFlipVertically = false - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = false, - animate = false, - ) - mCropOverlayView!!.resetCropWindowRect() - } + /** Clears set aspect ratio values and sets fixed aspect ratio to FALSE. */ + fun clearAspectRatio() { + mCropOverlayView!!.aspectRatioX = 1 + mCropOverlayView.aspectRatioY = 1 + setFixedAspectRatio(false) + } - /** - * Gets the cropped image based on the current crop window. - * - * @return a new Bitmap representing the cropped image - */ - @Deprecated("Please use getCroppedImage", replaceWith = ReplaceWith("getCroppedImage()")) - @get:JvmName("-croppedImage") - val croppedImage: Bitmap? - get() = getCroppedImage(0, 0, RequestSizeOptions.NONE) - - /** - * Gets the cropped image based on the current crop window. - * - * [reqWidth] the width to resize the cropped image - * [reqHeight] the height to resize the cropped image - * [options] the resize method to use - * @return a new Bitmap representing the cropped image - */ - @JvmOverloads - fun getCroppedImage( - reqWidth: Int = 0, - reqHeight: Int = 0, - options: RequestSizeOptions = RequestSizeOptions.RESIZE_INSIDE, - ): Bitmap? { - if (originalBitmap != null) { - val newReqWidth = if (options != RequestSizeOptions.NONE) reqWidth else 0 - val newReqHeight = if (options != RequestSizeOptions.NONE) reqHeight else 0 - val croppedBitmap = if (imageUri != null && (loadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING)) { - BitmapUtils.cropBitmap( - context = context, - loadedImageUri = imageUri, - cropPoints = cropPoints, - degreesRotated = mDegreesRotated, - orgWidth = originalBitmap!!.width * loadedSampleSize, - orgHeight = originalBitmap!!.height * loadedSampleSize, - fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, - aspectRatioX = mCropOverlayView.aspectRatioX, - aspectRatioY = mCropOverlayView.aspectRatioY, - reqWidth = newReqWidth, - reqHeight = newReqHeight, - flipHorizontally = mFlipHorizontally, - flipVertically = mFlipVertically, - ).bitmap - } else { - BitmapUtils.cropBitmapObjectHandleOOM( - bitmap = originalBitmap, - cropPoints = cropPoints, - degreesRotated = mDegreesRotated, - fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, - aspectRatioX = mCropOverlayView.aspectRatioX, - aspectRatioY = mCropOverlayView.aspectRatioY, - flipHorizontally = mFlipHorizontally, - flipVertically = mFlipVertically, - ).bitmap - } - - return BitmapUtils.resizeBitmap( - bitmap = croppedBitmap, - reqWidth = newReqWidth, - reqHeight = newReqHeight, - options = options, - ) + /** + * 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. (default: 3dp) + */ + fun setSnapRadius(snapRadius: Float) { + if (snapRadius >= 0) mCropOverlayView!!.setSnapRadius(snapRadius) } - return null - } + /** + * if to show progress bar when image async loading/cropping is in progress.

+ * default: true, disable to provide custom progress bar UI. + */ + private var isShowProgressBar: Boolean + get() = mShowProgressBar + set(showProgressBar) { + if (mShowProgressBar != showProgressBar) { + mShowProgressBar = showProgressBar + setProgressBarVisibility() + } + } - /** - * Cropped image based on the current crop window to the given uri. - * The result will be invoked to listener set by [setOnCropImageCompleteListener]. - * - * [saveCompressFormat] the compression format to use when writing the image - * [saveCompressQuality] the quality (if applicable) to use when writing the image (0 - 100) - * [reqWidth] the width to resize the cropped image - * [reqHeight] the height to resize the cropped image - * [options] the resize method to use, see its documentation - */ - fun croppedImageAsync( - saveCompressFormat: CompressFormat = CompressFormat.JPEG, - saveCompressQuality: Int = 90, - reqWidth: Int = 0, - reqHeight: Int = 0, - options: RequestSizeOptions = RequestSizeOptions.RESIZE_INSIDE, - customOutputUri: Uri? = null, - ) { - requireNotNull(mOnCropImageCompleteListener) { "mOnCropImageCompleteListener is not set" } - startCropWorkerTask( - reqWidth = reqWidth, - reqHeight = reqHeight, - options = options, - saveCompressFormat = saveCompressFormat, - saveCompressQuality = saveCompressQuality, - customOutputUri = customOutputUri, - ) - } + /** + * if to show crop overlay UI what contains the crop window UI surrounded by background over the + * cropping image.

+ * default: true, may disable for animation or frame transition. + */ + private var isShowCropOverlay: Boolean + get() = mShowCropOverlay + set(showCropOverlay) { + if (mShowCropOverlay != showCropOverlay) { + mShowCropOverlay = showCropOverlay + setCropOverlayVisibility() + } + } - /** Set the callback t */ - fun setOnSetCropOverlayReleasedListener(listener: OnSetCropOverlayReleasedListener?) { - mOnCropOverlayReleasedListener = listener - } + /** + * If enabled, show a text label on top of crop overlay UI, which gets moved along with the cropper + */ + var isShowCropLabel: Boolean + get() = mShowCropLabel + set(showCropLabel) { + if (mShowCropLabel != showCropLabel) { + mShowCropLabel = showCropLabel + mCropOverlayView?.setCropperTextLabelVisibility(mShowCropLabel) + } + } + var cropLabelText: String + get() = mCropTextLabel + set(cropLabelText) { + mCropTextLabel = cropLabelText + mCropOverlayView?.setCropLabelText(cropLabelText) + } + var cropLabelTextSize: Float + get() = mCropLabelTextSize + set(textSize) { + mCropLabelTextSize = cropLabelTextSize + mCropOverlayView?.setCropLabelTextSize(textSize) + } + var cropLabelTextColor: Int + get() = mCropLabelTextColor + set(cropLabelTextColor) { + mCropLabelTextColor = cropLabelTextColor + mCropOverlayView?.setCropLabelTextColor(cropLabelTextColor) + } + /** Returns the integer of the imageResource */ + /** + * Sets a Drawable as the content of the CropImageView. + * + * resId the drawable resource ID to set + */ + private var imageResource: Int + get() = mImageResource + set(resId) { + if (resId != 0) { + mCropOverlayView!!.initialCropWindowRect = null + val bitmap = BitmapFactory.decodeResource(resources, resId) + setBitmap( + bitmap = bitmap, + imageResource = resId, + imageUri = null, + loadSampleSize = 1, + degreesRotated = 0, + ) + } + } - /** Set the callback when the cropping is moved */ - fun setOnSetCropOverlayMovedListener(listener: OnSetCropOverlayMovedListener?) { - mOnSetCropOverlayMovedListener = listener - } + /** + * Gets the source Bitmap's dimensions. This represents the largest possible crop rectangle. + * + * @return a Rect instance dimensions of the source Bitmap + */ + val wholeImageRect: Rect? + get() { + val loadedSampleSize = loadedSampleSize + val bitmap = originalBitmap ?: return null + val orgWidth = bitmap.width * loadedSampleSize + val orgHeight = bitmap.height * loadedSampleSize + return Rect(0, 0, orgWidth, orgHeight) + } - /** Set the callback when the crop window is changed */ - fun setOnCropWindowChangedListener(listener: OnSetCropWindowChangeListener?) { - mOnSetCropWindowChangeListener = listener - } + /** + * Gets the crop window's position relative to the source Bitmap (not the image displayed in the + * CropImageView) using the original image rotation. + * + * @return a Rect instance containing cropped area boundaries of the source Bitmap + * + * Set the crop window position and size to the given rectangle.

+ * Image to crop must be first set before invoking this, for async - after complete callback. + * + * rect window rectangle (position and size) relative to source bitmap + */ + var cropRect: Rect? + get() { + val loadedSampleSize = loadedSampleSize + val bitmap = originalBitmap ?: return null + // get the points of the crop rectangle adjusted to source bitmap + val points = cropPoints + val orgWidth = bitmap.width * loadedSampleSize + val orgHeight = bitmap.height * loadedSampleSize + // get the rectangle for the points (it may be larger than original if rotation is not straight) + return BitmapUtils.getRectFromPoints( + cropPoints = points, + imageWidth = orgWidth, + imageHeight = orgHeight, + fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, + aspectRatioX = mCropOverlayView.aspectRatioX, + aspectRatioY = mCropOverlayView.aspectRatioY, + ) + } + set(rect) { + mCropOverlayView!!.initialCropWindowRect = rect + } - /** - * Set the callback to be invoked when image async loading [setImageUriAsync] is - * complete (successful or failed). - */ - fun setOnSetImageUriCompleteListener(listener: OnSetImageUriCompleteListener?) { - mOnSetImageUriCompleteListener = listener - } + /** This returns the expected image size, if cropping the image right now. */ + fun expectedImageSize(): Size? { + val rect = cropRect ?: return null - /** - * Set the callback to be invoked when image async cropping image [croppedImageAsync] - * or [croppedImage] is complete (successful or failed). - */ - fun setOnCropImageCompleteListener(listener: OnCropImageCompleteListener?) { - mOnCropImageCompleteListener = listener - } + return if (rotatedDegrees == 0 || rotatedDegrees == 180) { + Size(rect.width(), rect.height()) + } else { + Size(rect.height(), rect.width()) + } + } - /** - * Sets a Bitmap as the content of the CropImageView. - * - * [bitmap] the Bitmap to set - */ - fun setImageBitmap(bitmap: Bitmap?) { - mCropOverlayView!!.initialCropWindowRect = null - setBitmap( - bitmap = bitmap, - imageResource = 0, - imageUri = null, - loadSampleSize = 1, - degreesRotated = 0, - ) - } + /** + * Gets the crop window's position relative to the parent's view at screen. + * + * @return a Rect instance containing cropped area boundaries of the source Bitmap + */ + val cropWindowRect: RectF? + get() = mCropOverlayView?.cropWindowRect // Get crop window position relative to the displayed image. + + /** + * Gets the 4 points of crop window's position relative to the source Bitmap (not the image + * displayed in the CropImageView) using the original image rotation.

+ * Note: the 4 points may not be a rectangle if the image was rotates to NOT straight angle (!= + * 90/180/270). + * + * @return 4 points (x0,y0,x1,y1,x2,y2,x3,y3) of cropped area boundaries + */ + val cropPoints: FloatArray + get() { + // Get crop window position relative to the displayed image. + val cropWindowRect = mCropOverlayView!!.cropWindowRect + val points = floatArrayOf( + cropWindowRect.left, + cropWindowRect.top, + cropWindowRect.right, + cropWindowRect.top, + cropWindowRect.right, + cropWindowRect.bottom, + cropWindowRect.left, + cropWindowRect.bottom, + ) + mImageMatrix.invert(mImageInverseMatrix) + mImageInverseMatrix.mapPoints(points) + val resultPoints = FloatArray(points.size) + for (i in points.indices) { + resultPoints[i] = points[i] * loadedSampleSize + } + return resultPoints + } - /** - * Sets a Bitmap and initializes the image rotation according to the EXIT data.

- *

- * The EXIF can be retrieved by doing the following: ` - * ExifInterface exif = new ExifInterface(path);` - * - * [bitmap] the original bitmap to set; if null, this - * [exif] the EXIF information about this bitmap; may be null - */ - fun setImageBitmap(bitmap: Bitmap?, exif: ExifInterface?) { - val setBitmap: Bitmap? - var degreesRotated = 0 - if (bitmap != null && exif != null) { - val result = BitmapUtils.orientateBitmapByExif(bitmap, exif) - setBitmap = result.bitmap - degreesRotated = result.degrees - mFlipHorizontally = result.flipHorizontally - mFlipVertically = result.flipVertically - mInitialDegreesRotated = result.degrees - } else { - setBitmap = bitmap + /** Reset crop window to initial rectangle. */ + fun resetCropRect() { + mZoom = 1f + mZoomOffsetX = 0f + mZoomOffsetY = 0f + mDegreesRotated = mInitialDegreesRotated + mFlipHorizontally = false + mFlipVertically = false + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = false, + animate = false, + ) + mCropOverlayView!!.resetCropWindowRect() } - mCropOverlayView!!.initialCropWindowRect = null - setBitmap( - bitmap = setBitmap, - imageResource = 0, - imageUri = null, - loadSampleSize = 1, - degreesRotated = degreesRotated, - ) - } + /** + * Gets the cropped image based on the current crop window. + * + * @return a new Bitmap representing the cropped image + */ + @Deprecated("Please use getCroppedImage", replaceWith = ReplaceWith("getCroppedImage()")) + @get:JvmName("-croppedImage") + val croppedImage: Bitmap? + get() = getCroppedImage(0, 0, RequestSizeOptions.NONE) - /** - * Sets a bitmap loaded from the given Android URI as the content of the CropImageView.

- * Can be used with URI from gallery or camera source.

- * Will rotate the image by exif data.

- * - * [uri] the URI to load the image from - */ - fun setImageUriAsync(uri: Uri?) { - if (uri != null) { - bitmapLoadingWorkerJob?.get()?.cancel() - clearImageInt() - mCropOverlayView!!.initialCropWindowRect = null - bitmapLoadingWorkerJob = WeakReference(BitmapLoadingWorkerJob(context, this, uri)) - bitmapLoadingWorkerJob?.get()?.start() - setProgressBarVisibility() - } - } + /** + * Gets the cropped image based on the current crop window. + * + * [reqWidth] the width to resize the cropped image + * [reqHeight] the height to resize the cropped image + * [options] the resize method to use + * @return a new Bitmap representing the cropped image + */ - /** Clear the current image set for cropping. */ - fun clearImage() { - clearImageInt() - mCropOverlayView?.initialCropWindowRect = null - } + private fun syncManualImageMatrixBeforeCrop() { + if (isManualMode) { + mImageMatrix.set(imageView.getCurrentMatrix()) + imageView.imageMatrix = mImageMatrix - /** - * Rotates image by the specified number of degrees clockwise.

- * Negative values represent counter-clockwise rotations. - * - * [degrees] Integer specifying the number of degrees to rotate. - */ - fun rotateImage(degrees: Int) { - if (originalBitmap != null) { - // Force degrees to be a non-zero value between 0 and 360 (inclusive) - val newDegrees = - if (degrees < 0) { - degrees % 360 + 360 - } else { - degrees % 360 + mapImagePointsByImageMatrix() + updateImageBounds(false) } - val flipAxes = ( - !mCropOverlayView!!.isFixAspectRatio && - (newDegrees in 46..134 || newDegrees in 216..304) - ) + } - BitmapUtils.RECT.set(mCropOverlayView.cropWindowRect) - var halfWidth = - (if (flipAxes) BitmapUtils.RECT.height() else BitmapUtils.RECT.width()) / 2f - var halfHeight = - (if (flipAxes) BitmapUtils.RECT.width() else BitmapUtils.RECT.height()) / 2f - - if (flipAxes) { - val isFlippedHorizontally = mFlipHorizontally - mFlipHorizontally = mFlipVertically - mFlipVertically = isFlippedHorizontally - } - mImageMatrix.invert(mImageInverseMatrix) - BitmapUtils.POINTS[0] = BitmapUtils.RECT.centerX() - BitmapUtils.POINTS[1] = BitmapUtils.RECT.centerY() - BitmapUtils.POINTS[2] = 0f - BitmapUtils.POINTS[3] = 0f - BitmapUtils.POINTS[4] = 1f - BitmapUtils.POINTS[5] = 0f - mImageInverseMatrix.mapPoints(BitmapUtils.POINTS) - // This is valid because degrees is not negative. - mDegreesRotated = (mDegreesRotated + newDegrees) % 360 - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - // adjust the zoom so the crop window size remains the same even after image scale change - mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS) - mZoom /= sqrt( - (BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2]).toDouble().pow(2.0) + - (BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3]).toDouble().pow(2.0), - ).toFloat() - mZoom = max(mZoom, 1f) - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS) - // adjust the width/height by the changes in scaling to the image - val change = sqrt( - (BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2]).toDouble().pow(2.0) + - (BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3]).toDouble().pow(2.0), - ) - halfWidth *= change.toFloat() - halfHeight *= change.toFloat() - // calculate the new crop window rectangle to center in the same location and have proper - // width/height - BitmapUtils.RECT[BitmapUtils.POINTS2[0] - halfWidth, BitmapUtils.POINTS2[1] - halfHeight, BitmapUtils.POINTS2[0] + halfWidth] = - BitmapUtils.POINTS2[1] + halfHeight - mCropOverlayView.resetCropOverlayView() - mCropOverlayView.cropWindowRect = BitmapUtils.RECT - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - handleCropWindowChanged(inProgress = false, animate = false) - // make sure the crop window rectangle is within the cropping image bounds after all the - // changes - mCropOverlayView.fixCurrentCropWindowRect() + @JvmOverloads + fun getCroppedImage( + reqWidth: Int = 0, + reqHeight: Int = 0, + options: RequestSizeOptions = RequestSizeOptions.RESIZE_INSIDE, + ): Bitmap? { + if (originalBitmap != null) { + syncManualImageMatrixBeforeCrop() + val newReqWidth = if (options != RequestSizeOptions.NONE) reqWidth else 0 + val newReqHeight = if (options != RequestSizeOptions.NONE) reqHeight else 0 + val croppedBitmap = + if (imageUri != null && (loadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING)) { + BitmapUtils.cropBitmap( + context = context, + loadedImageUri = imageUri, + cropPoints = cropPoints, + degreesRotated = mDegreesRotated, + orgWidth = originalBitmap!!.width * loadedSampleSize, + orgHeight = originalBitmap!!.height * loadedSampleSize, + fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, + aspectRatioX = mCropOverlayView.aspectRatioX, + aspectRatioY = mCropOverlayView.aspectRatioY, + reqWidth = newReqWidth, + reqHeight = newReqHeight, + flipHorizontally = mFlipHorizontally, + flipVertically = mFlipVertically, + ).bitmap + } else { + BitmapUtils.cropBitmapObjectHandleOOM( + bitmap = originalBitmap, + cropPoints = cropPoints, + degreesRotated = mDegreesRotated, + fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, + aspectRatioX = mCropOverlayView.aspectRatioX, + aspectRatioY = mCropOverlayView.aspectRatioY, + flipHorizontally = mFlipHorizontally, + flipVertically = mFlipVertically, + ).bitmap + } + + return BitmapUtils.resizeBitmap( + bitmap = croppedBitmap, + reqWidth = newReqWidth, + reqHeight = newReqHeight, + options = options, + ) + } + + return null } - } - /** Flips the image horizontally. */ - fun flipImageHorizontally() { - mFlipHorizontally = !mFlipHorizontally - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - } + /** + * Cropped image based on the current crop window to the given uri. + * The result will be invoked to listener set by [setOnCropImageCompleteListener]. + * + * [saveCompressFormat] the compression format to use when writing the image + * [saveCompressQuality] the quality (if applicable) to use when writing the image (0 - 100) + * [reqWidth] the width to resize the cropped image + * [reqHeight] the height to resize the cropped image + * [options] the resize method to use, see its documentation + */ + fun croppedImageAsync( + saveCompressFormat: CompressFormat = CompressFormat.JPEG, + saveCompressQuality: Int = 90, + reqWidth: Int = 0, + reqHeight: Int = 0, + options: RequestSizeOptions = RequestSizeOptions.RESIZE_INSIDE, + customOutputUri: Uri? = null, + ) { + requireNotNull(mOnCropImageCompleteListener) { "mOnCropImageCompleteListener is not set" } + syncManualImageMatrixBeforeCrop() + startCropWorkerTask( + reqWidth = reqWidth, + reqHeight = reqHeight, + options = options, + saveCompressFormat = saveCompressFormat, + saveCompressQuality = saveCompressQuality, + customOutputUri = customOutputUri, + ) + } - /** Flips the image vertically. */ - fun flipImageVertically() { - mFlipVertically = !mFlipVertically - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - } + /** Set the callback t */ + fun setOnSetCropOverlayReleasedListener(listener: OnSetCropOverlayReleasedListener?) { + mOnCropOverlayReleasedListener = listener + } - /** - * On complete of the async bitmap loading by [setImageUriAsync] set the result to the - * widget if still relevant and call listener if set. - * - * [result] the result of bitmap loading - */ - internal fun onSetImageUriAsyncComplete(result: BitmapLoadingWorkerJob.Result) { - bitmapLoadingWorkerJob = null - setProgressBarVisibility() - if (result.error == null) { - mInitialDegreesRotated = result.degreesRotated - mFlipHorizontally = result.flipHorizontally - mFlipVertically = result.flipVertically - setBitmap( - bitmap = result.bitmap, - imageResource = 0, - imageUri = result.uri, - loadSampleSize = result.loadSampleSize, - degreesRotated = result.degreesRotated, - ) + /** Set the callback when the cropping is moved */ + fun setOnSetCropOverlayMovedListener(listener: OnSetCropOverlayMovedListener?) { + mOnSetCropOverlayMovedListener = listener } - mOnSetImageUriCompleteListener?.onSetImageUriComplete( - view = this, - uri = result.uri, - error = result.error, - ) - } - /** - * On complete of the async bitmap cropping by [croppedImageAsync] call listener if - * set. - * - * [result] the result of bitmap cropping - */ - internal fun onImageCroppingAsyncComplete(result: BitmapCroppingWorkerJob.Result) { - bitmapCroppingWorkerJob = null - setProgressBarVisibility() - val listener = mOnCropImageCompleteListener - if (listener != null) { - val cropResult = CropResult( - originalBitmap = originalBitmap, - originalUri = imageUri, - bitmap = result.bitmap, - uriContent = result.uri, - error = result.error, - cropPoints = cropPoints, - cropRect = cropRect, - wholeImageRect = wholeImageRect, - rotation = rotatedDegrees, - sampleSize = result.sampleSize, - ) - listener.onCropImageComplete(this, cropResult) + /** Set the callback when the crop window is changed */ + fun setOnCropWindowChangedListener(listener: OnSetCropWindowChangeListener?) { + mOnSetCropWindowChangeListener = listener } - } - /** - * Set the given bitmap to be used in for cropping

- * Optionally clear full if the bitmap is new, or partial clear if the bitmap has been - * manipulated. - */ - private fun setBitmap( - bitmap: Bitmap?, - imageResource: Int, - imageUri: Uri?, - loadSampleSize: Int, - degreesRotated: Int, - ) { - if (originalBitmap == null || originalBitmap != bitmap) { - clearImageInt() - originalBitmap = bitmap - imageView.setImageBitmap(originalBitmap) - this.imageUri = imageUri - mImageResource = imageResource - loadedSampleSize = loadSampleSize - mDegreesRotated = degreesRotated - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = true, - animate = false, - ) - if (mCropOverlayView != null) { - mCropOverlayView.resetCropOverlayView() - setCropOverlayVisibility() - } + /** + * Set the callback to be invoked when image async loading [setImageUriAsync] is + * complete (successful or failed). + */ + fun setOnSetImageUriCompleteListener(listener: OnSetImageUriCompleteListener?) { + mOnSetImageUriCompleteListener = listener } - } - /** - * Clear the current image set for cropping.

- * Full clear will also clear the data of the set image like Uri or Resource id while partial - * clear will only clear the bitmap and recycle if required. - */ - private fun clearImageInt() { - // if we allocated the bitmap, release it as fast as possible - if (originalBitmap != null && (mImageResource > 0 || imageUri != null)) { - originalBitmap!!.recycle() + /** + * Set the callback to be invoked when image async cropping image [croppedImageAsync] + * or [croppedImage] is complete (successful or failed). + */ + fun setOnCropImageCompleteListener(listener: OnCropImageCompleteListener?) { + mOnCropImageCompleteListener = listener } - originalBitmap = null - // clean the loaded image flags for new image - mImageResource = 0 - imageUri = null - loadedSampleSize = 1 - mDegreesRotated = 0 - mZoom = 1f - mZoomOffsetX = 0f - mZoomOffsetY = 0f - mImageMatrix.reset() - mRestoreCropWindowRect = null - mRestoreDegreesRotated = 0 - imageView.setImageBitmap(null) - setCropOverlayVisibility() - } - /** - * Gets the cropped image based on the current crop window. - * If (reqWidth,reqHeight) is given AND image is loaded from URI cropping will try to use sample - * size to fit in the requested width and height down-sampling if possible - optimization to get - * best size to quality. - * The result will be invoked to listener set by [setOnCropImageCompleteListener]. - * - * [reqWidth] the width to resize the cropped image - * [reqHeight] the height to resize the cropped image - * [options] the resize method to use on the cropped bitmap - * [saveCompressFormat] if saveUri is given, the given compression will be used for saving - * the image - * [saveCompressQuality] if saveUri is given, the given quality will be used for the - * compression. - */ - fun startCropWorkerTask( - reqWidth: Int, - reqHeight: Int, - options: RequestSizeOptions, - saveCompressFormat: CompressFormat, - saveCompressQuality: Int, - customOutputUri: Uri?, - ) { - val bitmap = originalBitmap - if (bitmap != null) { - val currentTask = - if (bitmapCroppingWorkerJob != null) bitmapCroppingWorkerJob!!.get() else null - currentTask?.cancel() - - val (orgWidth, orgHeight) = - if (loadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING) { - Pair((bitmap.width * loadedSampleSize), (bitmap.height * loadedSampleSize)) + /** + * Sets a Bitmap as the content of the CropImageView. + * + * [bitmap] the Bitmap to set + */ + fun setImageBitmap(bitmap: Bitmap?) { + mCropOverlayView!!.initialCropWindowRect = null + setBitmap( + bitmap = bitmap, + imageResource = 0, + imageUri = null, + loadSampleSize = 1, + degreesRotated = 0, + ) + } + + /** + * Sets a Bitmap and initializes the image rotation according to the EXIT data.

+ *

+ * The EXIF can be retrieved by doing the following: ` + * ExifInterface exif = new ExifInterface(path);` + * + * [bitmap] the original bitmap to set; if null, this + * [exif] the EXIF information about this bitmap; may be null + */ + fun setImageBitmap(bitmap: Bitmap?, exif: ExifInterface?) { + val setBitmap: Bitmap? + var degreesRotated = 0 + if (bitmap != null && exif != null) { + val result = BitmapUtils.orientateBitmapByExif(bitmap, exif) + setBitmap = result.bitmap + degreesRotated = result.degrees + mFlipHorizontally = result.flipHorizontally + mFlipVertically = result.flipVertically + mInitialDegreesRotated = result.degrees } else { - Pair(0, 0) + setBitmap = bitmap } - bitmapCroppingWorkerJob = WeakReference( - BitmapCroppingWorkerJob( - context = context, - cropImageViewReference = WeakReference(this), - uri = imageUri, - bitmap = bitmap, - cropPoints = cropPoints, - degreesRotated = mDegreesRotated, - orgWidth = orgWidth, - orgHeight = orgHeight, - fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, - aspectRatioX = mCropOverlayView.aspectRatioX, - aspectRatioY = mCropOverlayView.aspectRatioY, - reqWidth = if (options != RequestSizeOptions.NONE) reqWidth else 0, - reqHeight = if (options != RequestSizeOptions.NONE) reqHeight else 0, - flipHorizontally = mFlipHorizontally, - flipVertically = mFlipVertically, - options = options, - saveCompressFormat = saveCompressFormat, - saveCompressQuality = saveCompressQuality, - customOutputUri = customOutputUri ?: this.customOutputUri, - ), - ) + mCropOverlayView!!.initialCropWindowRect = null + setBitmap( + bitmap = setBitmap, + imageResource = 0, + imageUri = null, + loadSampleSize = 1, + degreesRotated = degreesRotated, + ) + } - bitmapCroppingWorkerJob!!.get()!!.start() - setProgressBarVisibility() + /** + * Sets a bitmap loaded from the given Android URI as the content of the CropImageView.

+ * Can be used with URI from gallery or camera source.

+ * Will rotate the image by exif data.

+ * + * [uri] the URI to load the image from + */ + fun setImageUriAsync(uri: Uri?) { + if (uri != null) { + bitmapLoadingWorkerJob?.get()?.cancel() + clearImageInt() + mCropOverlayView!!.initialCropWindowRect = null + bitmapLoadingWorkerJob = WeakReference(BitmapLoadingWorkerJob(context, this, uri)) + bitmapLoadingWorkerJob?.get()?.start() + setProgressBarVisibility() + } } - } - public override fun onSaveInstanceState(): Parcelable? { - if (imageUri == null && originalBitmap == null && mImageResource < 1) { - return super.onSaveInstanceState() + /** Clear the current image set for cropping. */ + fun clearImage() { + clearImageInt() + mCropOverlayView?.initialCropWindowRect = null } - val bundle = Bundle() - @Suppress("DEPRECATION") val loadedImageUri = - if (isSaveBitmapToInstanceState && imageUri == null && mImageResource < 1) { - BitmapUtils.writeTempStateStoreBitmap( - context = context, - bitmap = originalBitmap, - customOutputUri = customOutputUri, - ) - } else { - imageUri - } - - if (loadedImageUri != null && originalBitmap != null) { - val key = UUID.randomUUID().toString() - BitmapUtils.mStateBitmap = Pair(key, WeakReference(originalBitmap)) - bundle.putString("LOADED_IMAGE_STATE_BITMAP_KEY", key) + /** + * Rotates image by the specified number of degrees clockwise.

+ * Negative values represent counter-clockwise rotations. + * + * [degrees] Integer specifying the number of degrees to rotate. + */ + fun rotateImage(degrees: Int) { + if (originalBitmap != null) { + // Force degrees to be a non-zero value between 0 and 360 (inclusive) + val newDegrees = + if (degrees < 0) { + degrees % 360 + 360 + } else { + degrees % 360 + } + val flipAxes = ( + !mCropOverlayView!!.isFixAspectRatio && + (newDegrees in 46..134 || newDegrees in 216..304) + ) + + BitmapUtils.RECT.set(mCropOverlayView.cropWindowRect) + var halfWidth = + (if (flipAxes) BitmapUtils.RECT.height() else BitmapUtils.RECT.width()) / 2f + var halfHeight = + (if (flipAxes) BitmapUtils.RECT.width() else BitmapUtils.RECT.height()) / 2f + + if (flipAxes) { + val isFlippedHorizontally = mFlipHorizontally + mFlipHorizontally = mFlipVertically + mFlipVertically = isFlippedHorizontally + } + mImageMatrix.invert(mImageInverseMatrix) + BitmapUtils.POINTS[0] = BitmapUtils.RECT.centerX() + BitmapUtils.POINTS[1] = BitmapUtils.RECT.centerY() + BitmapUtils.POINTS[2] = 0f + BitmapUtils.POINTS[3] = 0f + BitmapUtils.POINTS[4] = 1f + BitmapUtils.POINTS[5] = 0f + mImageInverseMatrix.mapPoints(BitmapUtils.POINTS) + // This is valid because degrees is not negative. + mDegreesRotated = (mDegreesRotated + newDegrees) % 360 + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, + ) + // adjust the zoom so the crop window size remains the same even after image scale change + mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS) + mZoom /= sqrt( + (BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2]).toDouble().pow(2.0) + + (BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3]).toDouble().pow(2.0), + ).toFloat() + mZoom = max(mZoom, 1f) + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, + ) + mImageMatrix.mapPoints(BitmapUtils.POINTS2, BitmapUtils.POINTS) + // adjust the width/height by the changes in scaling to the image + val change = sqrt( + (BitmapUtils.POINTS2[4] - BitmapUtils.POINTS2[2]).toDouble().pow(2.0) + + (BitmapUtils.POINTS2[5] - BitmapUtils.POINTS2[3]).toDouble().pow(2.0), + ) + halfWidth *= change.toFloat() + halfHeight *= change.toFloat() + // calculate the new crop window rectangle to center in the same location and have proper + // width/height + BitmapUtils.RECT[BitmapUtils.POINTS2[0] - halfWidth, BitmapUtils.POINTS2[1] - halfHeight, BitmapUtils.POINTS2[0] + halfWidth] = + BitmapUtils.POINTS2[1] + halfHeight + mCropOverlayView.resetCropOverlayView() + mCropOverlayView.cropWindowRect = BitmapUtils.RECT + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, + ) + handleCropWindowChanged(inProgress = false, animate = false) + // make sure the crop window rectangle is within the cropping image bounds after all the + // changes + mCropOverlayView.fixCurrentCropWindowRect() + } + imageView.setImageManuallyRotatedDegrees(degrees) // ->> this thing was added } - val task = bitmapLoadingWorkerJob?.get() - if (task != null) { - bundle.putParcelable("LOADING_IMAGE_URI", task.uri) + /** Flips the image horizontally. */ + fun flipImageHorizontally() { + imageView.flipImageHorizontally() } - bundle.putParcelable("instanceState", super.onSaveInstanceState()) - bundle.putParcelable("LOADED_IMAGE_URI", loadedImageUri) - bundle.putInt("LOADED_IMAGE_RESOURCE", mImageResource) - bundle.putInt("LOADED_SAMPLE_SIZE", loadedSampleSize) - bundle.putInt("DEGREES_ROTATED", mDegreesRotated) - bundle.putParcelable("INITIAL_CROP_RECT", mCropOverlayView!!.initialCropWindowRect) - BitmapUtils.RECT.set(mCropOverlayView.cropWindowRect) - mImageMatrix.invert(mImageInverseMatrix) - mImageInverseMatrix.mapRect(BitmapUtils.RECT) - bundle.putParcelable("CROP_WINDOW_RECT", BitmapUtils.RECT) - bundle.putString("CROP_SHAPE", mCropOverlayView.cropShape!!.name) - bundle.putBoolean("CROP_AUTO_ZOOM_ENABLED", mAutoZoomEnabled) - bundle.putInt("CROP_MAX_ZOOM", mMaxZoom) - bundle.putBoolean("CROP_FLIP_HORIZONTALLY", mFlipHorizontally) - bundle.putBoolean("CROP_FLIP_VERTICALLY", mFlipVertically) - bundle.putBoolean("SHOW_CROP_LABEL", mShowCropLabel) - return bundle - } + /** Flips the image vertically. */ + fun flipImageVertically() { + imageView.flipImageVertically() + } - public override fun onRestoreInstanceState(state: Parcelable) { - if (state is Bundle) { - // prevent restoring state if already set by outside code - if (bitmapLoadingWorkerJob == null && imageUri == null && originalBitmap == null && mImageResource == 0) { - var uri = state.parcelable("LOADED_IMAGE_URI") - if (uri != null) { - val key = state.getString("LOADED_IMAGE_STATE_BITMAP_KEY") - 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( - bitmap = stateBitmap, + /** + * On complete of the async bitmap loading by [setImageUriAsync] set the result to the + * widget if still relevant and call listener if set. + * + * [result] the result of bitmap loading + */ + internal fun onSetImageUriAsyncComplete(result: BitmapLoadingWorkerJob.Result) { + bitmapLoadingWorkerJob = null + setProgressBarVisibility() + if (result.error == null) { + mInitialDegreesRotated = result.degreesRotated + mFlipHorizontally = result.flipHorizontally + mFlipVertically = result.flipVertically + setBitmap( + bitmap = result.bitmap, imageResource = 0, - imageUri = uri, - loadSampleSize = state.getInt("LOADED_SAMPLE_SIZE"), - degreesRotated = 0, - ) - } - } - imageUri ?: setImageUriAsync(uri) - } else { - val resId = state.getInt("LOADED_IMAGE_RESOURCE") - - if (resId > 0) { - imageResource = resId - } else { - uri = state.parcelable("LOADING_IMAGE_URI") - uri?.let { setImageUriAsync(it) } - } - } - mRestoreDegreesRotated = state.getInt("DEGREES_ROTATED") - mDegreesRotated = mRestoreDegreesRotated - val initialCropRect = state.parcelable("INITIAL_CROP_RECT") - if (initialCropRect != null && - (initialCropRect.width() > 0 || initialCropRect.height() > 0) - ) { - mCropOverlayView!!.initialCropWindowRect = initialCropRect - } - val cropWindowRect = state.parcelable("CROP_WINDOW_RECT") - if (cropWindowRect != null && (cropWindowRect.width() > 0 || cropWindowRect.height() > 0)) { - mRestoreCropWindowRect = cropWindowRect + imageUri = result.uri, + loadSampleSize = result.loadSampleSize, + degreesRotated = result.degreesRotated, + ) } - mCropOverlayView!!.setCropShape( - CropShape.valueOf( - state.getString("CROP_SHAPE")!!, - ), + mOnSetImageUriCompleteListener?.onSetImageUriComplete( + view = this, + uri = result.uri, + error = result.error, ) - mAutoZoomEnabled = state.getBoolean("CROP_AUTO_ZOOM_ENABLED") - mMaxZoom = state.getInt("CROP_MAX_ZOOM") - mFlipHorizontally = state.getBoolean("CROP_FLIP_HORIZONTALLY") - mFlipVertically = state.getBoolean("CROP_FLIP_VERTICALLY") - mShowCropLabel = state.getBoolean("SHOW_CROP_LABEL") - mCropOverlayView.setCropperTextLabelVisibility(mShowCropLabel) - } - super.onRestoreInstanceState(state.parcelable("instanceState")) - } else { - super.onRestoreInstanceState(state) } - } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - val widthMode = MeasureSpec.getMode(widthMeasureSpec) - val widthSize = MeasureSpec.getSize(widthMeasureSpec) - val heightMode = MeasureSpec.getMode(heightMeasureSpec) - var heightSize = MeasureSpec.getSize(heightMeasureSpec) - val bitmap = originalBitmap - if (bitmap != null) { - // Bypasses a baffling bug when used within a ScrollView, where heightSize is set to 0. - 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 < bitmap.width) { - viewToBitmapWidthRatio = widthSize.toDouble() / bitmap.width.toDouble() - } - if (heightSize < bitmap.height) { - viewToBitmapHeightRatio = heightSize.toDouble() / bitmap.height.toDouble() - } - // If either needs to be fixed, choose the smallest ratio and calculate from there - if (viewToBitmapWidthRatio != Double.POSITIVE_INFINITY || - viewToBitmapHeightRatio != Double.POSITIVE_INFINITY - ) { - if (viewToBitmapWidthRatio <= viewToBitmapHeightRatio) { - desiredWidth = widthSize - desiredHeight = (bitmap.height * viewToBitmapWidthRatio).toInt() - } else { - desiredHeight = heightSize - desiredWidth = (bitmap.width * viewToBitmapHeightRatio).toInt() + /** + * On complete of the async bitmap cropping by [croppedImageAsync] call listener if + * set. + * + * [result] the result of bitmap cropping + */ + internal fun onImageCroppingAsyncComplete(result: BitmapCroppingWorkerJob.Result) { + bitmapCroppingWorkerJob = null + setProgressBarVisibility() + val listener = mOnCropImageCompleteListener + if (listener != null) { + val cropResult = CropResult( + originalBitmap = originalBitmap, + originalUri = imageUri, + bitmap = result.bitmap, + uriContent = result.uri, + error = result.error, + cropPoints = cropPoints, + cropRect = cropRect, + wholeImageRect = wholeImageRect, + rotation = rotatedDegrees, + sampleSize = result.sampleSize, + ) + listener.onCropImageComplete(this, cropResult) } - } else { - // Otherwise, the picture is within frame layout bounds. Desired width is simply picture - // size - desiredWidth = bitmap.width - desiredHeight = bitmap.height - } - val width = getOnMeasureSpec(widthMode, widthSize, desiredWidth) - val height = getOnMeasureSpec(heightMode, heightSize, desiredHeight) - mLayoutWidth = width - mLayoutHeight = height - setMeasuredDimension(mLayoutWidth, mLayoutHeight) - } else { - setMeasuredDimension(widthSize, heightSize) } - } - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - super.onLayout(changed, l, t, r, b) - if (mLayoutWidth > 0 && mLayoutHeight > 0) { - // Gets original parameters, and creates the new parameters - val origParams = this.layoutParams - origParams.width = mLayoutWidth - origParams.height = mLayoutHeight - layoutParams = origParams - if (originalBitmap != null) { - applyImageMatrix( - (r - l).toFloat(), - (b - t).toFloat(), - center = true, - animate = false, - ) - // after state restore we want to restore the window crop, possible only after widget size - // is known - val restoreCropWindowRect = mRestoreCropWindowRect - if (restoreCropWindowRect != null) { - if (mRestoreDegreesRotated != mInitialDegreesRotated) { - mDegreesRotated = mRestoreDegreesRotated + /** + * Set the given bitmap to be used in for cropping

+ * Optionally clear full if the bitmap is new, or partial clear if the bitmap has been + * manipulated. + */ + private fun setBitmap( + bitmap: Bitmap?, + imageResource: Int, + imageUri: Uri?, + loadSampleSize: Int, + degreesRotated: Int, + ) { + if (originalBitmap == null || originalBitmap != bitmap) { + clearImageInt() + originalBitmap = bitmap + imageView.setImageBitmap(originalBitmap) + imageView.setOriginalBitmap(originalBitmap) + imageView.setOriginalUri(imageUri) + this.imageUri = imageUri + mImageResource = imageResource + loadedSampleSize = loadSampleSize + mDegreesRotated = degreesRotated applyImageMatrix( - width = (r - l).toFloat(), - height = (b - t).toFloat(), - center = true, - animate = false, + width = width.toFloat(), + height = height.toFloat(), + center = true, + animate = false, ) - mRestoreDegreesRotated = 0 - } - mImageMatrix.mapRect(mRestoreCropWindowRect) - mCropOverlayView?.cropWindowRect = restoreCropWindowRect - handleCropWindowChanged(inProgress = false, animate = false) - mCropOverlayView?.fixCurrentCropWindowRect() - mRestoreCropWindowRect = null - } else if (mSizeChanged) { - mSizeChanged = false - handleCropWindowChanged(inProgress = false, animate = false) + if (mCropOverlayView != null) { + mCropOverlayView.resetCropOverlayView() + setCropOverlayVisibility() + } } - } else { - updateImageBounds(true) - } - } else { - updateImageBounds(true) } - } - - /** Detect size change to handle auto-zoom using [handleCropWindowChanged] in layout. */ - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - mSizeChanged = oldw > 0 && oldh > 0 - } - /** - * Handle crop window change to:

- * 1. Execute auto-zoom-in/out depending on the area covered of cropping window relative to the - * available view area.

- * 2. Slide the zoomed sub-area if the cropping window is outside the visible view sub-area. - *

- * - * [inProgress] is the crop window change is still in progress by the user - * [animate] if to animate the change to the image matrix, or set it directly - */ - private fun handleCropWindowChanged(inProgress: Boolean, animate: Boolean) { - val width = width - val height = height - if (originalBitmap != null && width > 0 && height > 0) { - val cropRect = mCropOverlayView!!.cropWindowRect - if (inProgress) { - if (cropRect.left < 0 || cropRect.top < 0 || cropRect.right > width || cropRect.bottom > height) { - applyImageMatrix( - width = width.toFloat(), - height = height.toFloat(), - center = false, - animate = false, - ) + /** + * Clear the current image set for cropping.

+ * Full clear will also clear the data of the set image like Uri or Resource id while partial + * clear will only clear the bitmap and recycle if required. + */ + private fun clearImageInt() { + // if we allocated the bitmap, release it as fast as possible + if (originalBitmap != null && (mImageResource > 0 || imageUri != null)) { + originalBitmap!!.recycle() } - } else if (mAutoZoomEnabled || mZoom > 1) { - var newZoom = 0f - // keep the cropping window covered area to 50%-65% of zoomed sub-area - if (mZoom < mMaxZoom && cropRect.width() < width * 0.5f && cropRect.height() < height * 0.5f) { - newZoom = min( - mMaxZoom.toFloat(), - min( - width / (cropRect.width() / mZoom / 0.64f), - height / (cropRect.height() / mZoom / 0.64f), - ), - ) + originalBitmap = null + // clean the loaded image flags for new image + mImageResource = 0 + imageUri = null + loadedSampleSize = 1 + mDegreesRotated = 0 + mZoom = 1f + mZoomOffsetX = 0f + mZoomOffsetY = 0f + mImageMatrix.reset() + mRestoreCropWindowRect = null + mRestoreDegreesRotated = 0 + imageView.setImageBitmap(null) + setCropOverlayVisibility() + } + + /** + * Gets the cropped image based on the current crop window. + * If (reqWidth,reqHeight) is given AND image is loaded from URI cropping will try to use sample + * size to fit in the requested width and height down-sampling if possible - optimization to get + * best size to quality. + * The result will be invoked to listener set by [setOnCropImageCompleteListener]. + * + * [reqWidth] the width to resize the cropped image + * [reqHeight] the height to resize the cropped image + * [options] the resize method to use on the cropped bitmap + * [saveCompressFormat] if saveUri is given, the given compression will be used for saving + * the image + * [saveCompressQuality] if saveUri is given, the given quality will be used for the + * compression. + */ + fun startCropWorkerTask( + reqWidth: Int, + reqHeight: Int, + options: RequestSizeOptions, + saveCompressFormat: CompressFormat, + saveCompressQuality: Int, + customOutputUri: Uri?, + ) { + val bitmap = originalBitmap + if (bitmap != null) { + val currentTask = + if (bitmapCroppingWorkerJob != null) bitmapCroppingWorkerJob!!.get() else null + currentTask?.cancel() + + val (orgWidth, orgHeight) = + if (loadedSampleSize > 1 || options == RequestSizeOptions.SAMPLING) { + Pair((bitmap.width * loadedSampleSize), (bitmap.height * loadedSampleSize)) + } else { + Pair(0, 0) + } + + bitmapCroppingWorkerJob = WeakReference( + BitmapCroppingWorkerJob( + context = context, + cropImageViewReference = WeakReference(this), + uri = imageUri, + bitmap = bitmap, + cropPoints = cropPoints, + degreesRotated = mDegreesRotated, + orgWidth = orgWidth, + orgHeight = orgHeight, + fixAspectRatio = mCropOverlayView!!.isFixAspectRatio, + aspectRatioX = mCropOverlayView.aspectRatioX, + aspectRatioY = mCropOverlayView.aspectRatioY, + reqWidth = if (options != RequestSizeOptions.NONE) reqWidth else 0, + reqHeight = if (options != RequestSizeOptions.NONE) reqHeight else 0, + flipHorizontally = mFlipHorizontally, + flipVertically = mFlipVertically, + options = options, + saveCompressFormat = saveCompressFormat, + saveCompressQuality = saveCompressQuality, + customOutputUri = customOutputUri ?: this.customOutputUri, + ), + ) + + bitmapCroppingWorkerJob!!.get()!!.start() + setProgressBarVisibility() } - if (mZoom > 1 && (cropRect.width() > width * 0.65f || cropRect.height() > height * 0.65f)) { - newZoom = max( - 1f, - min( - width / (cropRect.width() / mZoom / 0.51f), - height / (cropRect.height() / mZoom / 0.51f), - ), - ) + } + + public override fun onSaveInstanceState(): Parcelable? { + if (imageUri == null && originalBitmap == null && mImageResource < 1) { + return super.onSaveInstanceState() } - if (!mAutoZoomEnabled) newZoom = 1f - if (newZoom > 0 && newZoom != mZoom) { - if (animate) { - if (mAnimation == null) { - // lazy create animation single instance - mAnimation = CropImageAnimation(imageView, mCropOverlayView) + val bundle = Bundle() + @Suppress("DEPRECATION") val loadedImageUri = + if (isSaveBitmapToInstanceState && imageUri == null && mImageResource < 1) { + BitmapUtils.writeTempStateStoreBitmap( + context = context, + bitmap = originalBitmap, + customOutputUri = customOutputUri, + ) + } else { + imageUri } - // set the state for animation to start from - mAnimation!!.setStartState(mImagePoints, mImageMatrix) - } - mZoom = newZoom - applyImageMatrix(width.toFloat(), height.toFloat(), true, animate) + + if (loadedImageUri != null && originalBitmap != null) { + val key = UUID.randomUUID().toString() + BitmapUtils.mStateBitmap = Pair(key, WeakReference(originalBitmap)) + bundle.putString("LOADED_IMAGE_STATE_BITMAP_KEY", key) } - } - if (mOnSetCropWindowChangeListener != null && !inProgress) { - mOnSetCropWindowChangeListener!!.onCropWindowChanged() - } - } - } - /** - * Apply matrix to handle the image inside the image view. - * - * [width] the width of the image view - * [height] the height of the image view - */ - private fun applyImageMatrix(width: Float, height: Float, center: Boolean, animate: Boolean) { - val bitmap = originalBitmap - 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 - bitmap.width) / 2, - (height - bitmap.height) / 2, - ) - mapImagePointsByImageMatrix() - // rotate the image the required degrees from center of image - if (mDegreesRotated > 0) { - mImageMatrix.postRotate( - mDegreesRotated.toFloat(), - BitmapUtils.getRectCenterX(mImagePoints), - BitmapUtils.getRectCenterY(mImagePoints), - ) - mapImagePointsByImageMatrix() - } - // scale the image to the image view, image rect transformed to know new width/height - val scale = min( - width / BitmapUtils.getRectWidth(mImagePoints), - height / BitmapUtils.getRectHeight(mImagePoints), - ) - if (mScaleType == ScaleType.FIT_CENTER || mScaleType == ScaleType.CENTER_INSIDE && scale < 1 || - scale > 1 && mAutoZoomEnabled - ) { - mImageMatrix.postScale( - scale, - scale, - BitmapUtils.getRectCenterX(mImagePoints), - BitmapUtils.getRectCenterY(mImagePoints), - ) - mapImagePointsByImageMatrix() - } else if (mScaleType == ScaleType.CENTER_CROP) { - mZoom = max( - getWidth() / BitmapUtils.getRectWidth(mImagePoints), - getHeight() / BitmapUtils.getRectHeight(mImagePoints), - ) - } - // scale by the current zoom level - val scaleX = if (mFlipHorizontally) -mZoom else mZoom - val scaleY = if (mFlipVertically) -mZoom else mZoom - mImageMatrix.postScale( - scaleX, - scaleY, - BitmapUtils.getRectCenterX(mImagePoints), - BitmapUtils.getRectCenterY(mImagePoints), - ) - mapImagePointsByImageMatrix() - mImageMatrix.mapRect(cropRect) + val task = bitmapLoadingWorkerJob?.get() + if (task != null) { + bundle.putParcelable("LOADING_IMAGE_URI", task.uri) + } - if (mScaleType == ScaleType.CENTER_CROP && center && !animate) { - mZoomOffsetX = 0f - mZoomOffsetY = 0f - } else if (center) { - // set the zoomed area to be as to the center of cropping window as possible - mZoomOffsetX = - if (width > BitmapUtils.getRectWidth(mImagePoints)) { - 0f - } else { - max( - min( - width / 2 - cropRect.centerX(), - -BitmapUtils.getRectLeft(mImagePoints), - ), - getWidth() - BitmapUtils.getRectRight(mImagePoints), - ) / scaleX - } - - mZoomOffsetY = - if (height > BitmapUtils.getRectHeight(mImagePoints)) { - 0f - } else { - max( - min( - height / 2 - cropRect.centerY(), - -BitmapUtils.getRectTop(mImagePoints), - ), - getHeight() - BitmapUtils.getRectBottom(mImagePoints), - ) / scaleY - } - } else { - // adjust the zoomed area so the crop window rectangle will be inside the area in case it - // was moved outside - mZoomOffsetX = ( - min( - max(mZoomOffsetX * scaleX, -cropRect.left), - -cropRect.right + width, - ) / scaleX - ) - - mZoomOffsetY = ( - min( - max(mZoomOffsetY * scaleY, -cropRect.top), - -cropRect.bottom + height, - ) / scaleY - ) - } - // apply to zoom offset translate and update the crop rectangle to offset correctly - mImageMatrix.postTranslate(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY) - cropRect.offset(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY) - mCropOverlayView.cropWindowRect = cropRect - mapImagePointsByImageMatrix() - mCropOverlayView.invalidate() - // set matrix to apply - if (animate) { - // set the state for animation to end in, start animation now - mAnimation!!.setEndState(mImagePoints, mImageMatrix) - imageView.startAnimation(mAnimation) - } else { - imageView.imageMatrix = mImageMatrix - } - // update the image rectangle in the crop overlay - updateImageBounds(false) + bundle.putParcelable("instanceState", super.onSaveInstanceState()) + bundle.putParcelable("LOADED_IMAGE_URI", loadedImageUri) + bundle.putInt("LOADED_IMAGE_RESOURCE", mImageResource) + bundle.putInt("LOADED_SAMPLE_SIZE", loadedSampleSize) + bundle.putInt("DEGREES_ROTATED", mDegreesRotated) + bundle.putParcelable("INITIAL_CROP_RECT", mCropOverlayView!!.initialCropWindowRect) + BitmapUtils.RECT.set(mCropOverlayView.cropWindowRect) + mImageMatrix.invert(mImageInverseMatrix) + mImageInverseMatrix.mapRect(BitmapUtils.RECT) + bundle.putParcelable("CROP_WINDOW_RECT", BitmapUtils.RECT) + bundle.putString("CROP_SHAPE", mCropOverlayView.cropShape!!.name) + bundle.putBoolean("CROP_AUTO_ZOOM_ENABLED", mAutoZoomEnabled) + bundle.putInt("CROP_MAX_ZOOM", mMaxZoom) + bundle.putBoolean("CROP_FLIP_HORIZONTALLY", mFlipHorizontally) + bundle.putBoolean("CROP_FLIP_VERTICALLY", mFlipVertically) + bundle.putBoolean("SHOW_CROP_LABEL", mShowCropLabel) + return bundle } - } - - /** - * Adjust the given image rectangle by image transformation matrix to know the final rectangle of - * the image.

- * To get the proper rectangle it must be first reset to original image rectangle. - */ - private fun mapImagePointsByImageMatrix() { - mImagePoints[0] = 0f - mImagePoints[1] = 0f - mImagePoints[2] = originalBitmap!!.width.toFloat() - mImagePoints[3] = 0f - mImagePoints[4] = originalBitmap!!.width.toFloat() - mImagePoints[5] = originalBitmap!!.height.toFloat() - mImagePoints[6] = 0f - mImagePoints[7] = originalBitmap!!.height.toFloat() - mImageMatrix.mapPoints(mImagePoints) - mScaleImagePoints[0] = 0f - mScaleImagePoints[1] = 0f - mScaleImagePoints[2] = 100f - mScaleImagePoints[3] = 0f - mScaleImagePoints[4] = 100f - mScaleImagePoints[5] = 100f - mScaleImagePoints[6] = 0f - mScaleImagePoints[7] = 100f - mImageMatrix.mapPoints(mScaleImagePoints) - } - /** - * Set visibility of crop overlay to hide it when there is no image or specifically set by client. - */ - private fun setCropOverlayVisibility() { - if (mCropOverlayView != null) { - mCropOverlayView.visibility = - if (mShowCropOverlay && originalBitmap != null) VISIBLE else INVISIBLE + public override fun onRestoreInstanceState(state: Parcelable) { + if (state is Bundle) { + // prevent restoring state if already set by outside code + if (bitmapLoadingWorkerJob == null && imageUri == null && originalBitmap == null && mImageResource == 0) { + var uri = state.parcelable("LOADED_IMAGE_URI") + if (uri != null) { + val key = state.getString("LOADED_IMAGE_STATE_BITMAP_KEY") + 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( + bitmap = stateBitmap, + imageResource = 0, + imageUri = uri, + loadSampleSize = state.getInt("LOADED_SAMPLE_SIZE"), + degreesRotated = 0, + ) + } + } + imageUri ?: setImageUriAsync(uri) + } else { + val resId = state.getInt("LOADED_IMAGE_RESOURCE") + + if (resId > 0) { + imageResource = resId + } else { + uri = state.parcelable("LOADING_IMAGE_URI") + uri?.let { setImageUriAsync(it) } + } + } + mRestoreDegreesRotated = state.getInt("DEGREES_ROTATED") + mDegreesRotated = mRestoreDegreesRotated + val initialCropRect = state.parcelable("INITIAL_CROP_RECT") + if (initialCropRect != null && + (initialCropRect.width() > 0 || initialCropRect.height() > 0) + ) { + mCropOverlayView!!.initialCropWindowRect = initialCropRect + } + val cropWindowRect = state.parcelable("CROP_WINDOW_RECT") + if (cropWindowRect != null && (cropWindowRect.width() > 0 || cropWindowRect.height() > 0)) { + mRestoreCropWindowRect = cropWindowRect + } + mCropOverlayView!!.setCropShape( + CropShape.valueOf( + state.getString("CROP_SHAPE")!!, + ), + ) + mAutoZoomEnabled = state.getBoolean("CROP_AUTO_ZOOM_ENABLED") + mMaxZoom = state.getInt("CROP_MAX_ZOOM") + mFlipHorizontally = state.getBoolean("CROP_FLIP_HORIZONTALLY") + mFlipVertically = state.getBoolean("CROP_FLIP_VERTICALLY") + mShowCropLabel = state.getBoolean("SHOW_CROP_LABEL") + mCropOverlayView.setCropperTextLabelVisibility(mShowCropLabel) + } + super.onRestoreInstanceState(state.parcelable("instanceState")) + } else { + super.onRestoreInstanceState(state) + } } - } - /** - * Set visibility of progress bar when async loading/cropping is in process and show is enabled. - */ - private fun setProgressBarVisibility() { - val visible = ( - mShowProgressBar && - ( - originalBitmap == null && bitmapLoadingWorkerJob != null || - bitmapCroppingWorkerJob != null - ) - ) - mProgressBar.visibility = - if (visible) VISIBLE else INVISIBLE - } - - /** Update the scale factor between the actual image bitmap and the shown image.

*/ - private fun updateImageBounds(clear: Boolean) { - if (originalBitmap != null && !clear) { - // Get the scale factor between the actual Bitmap dimensions and the displayed dimensions for - // width/height. - val scaleFactorWidth = - 100f * loadedSampleSize / BitmapUtils.getRectWidth(mScaleImagePoints) - val scaleFactorHeight = - 100f * loadedSampleSize / BitmapUtils.getRectHeight(mScaleImagePoints) - mCropOverlayView!!.setCropWindowLimits( - width.toFloat(), - height.toFloat(), - scaleFactorWidth, - scaleFactorHeight, - ) + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + var heightSize = MeasureSpec.getSize(heightMeasureSpec) + val bitmap = originalBitmap + if (bitmap != null) { + // Bypasses a baffling bug when used within a ScrollView, where heightSize is set to 0. + 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 < bitmap.width) { + viewToBitmapWidthRatio = widthSize.toDouble() / bitmap.width.toDouble() + } + if (heightSize < bitmap.height) { + viewToBitmapHeightRatio = heightSize.toDouble() / bitmap.height.toDouble() + } + // If either needs to be fixed, choose the smallest ratio and calculate from there + if (viewToBitmapWidthRatio != Double.POSITIVE_INFINITY || + viewToBitmapHeightRatio != Double.POSITIVE_INFINITY + ) { + if (viewToBitmapWidthRatio <= viewToBitmapHeightRatio) { + desiredWidth = widthSize + desiredHeight = (bitmap.height * viewToBitmapWidthRatio).toInt() + } else { + desiredHeight = heightSize + desiredWidth = (bitmap.width * viewToBitmapHeightRatio).toInt() + } + } else { + // Otherwise, the picture is within frame layout bounds. Desired width is simply picture + // size + desiredWidth = bitmap.width + desiredHeight = bitmap.height + } + val width = getOnMeasureSpec(widthMode, widthSize, desiredWidth) + val height = getOnMeasureSpec(heightMode, heightSize, desiredHeight) + mLayoutWidth = width + mLayoutHeight = height + setMeasuredDimension(mLayoutWidth, mLayoutHeight) + } else { + setMeasuredDimension(widthSize, heightSize) + } } - // set the bitmap rectangle and update the crop window after scale factor is set - mCropOverlayView!!.setBounds(if (clear) null else mImagePoints, width, height) - } - /** - * The possible cropping area shape.

- * To set square/circle crop shape set aspect ratio to 1:1. - */ - enum class CropShape { - RECTANGLE, OVAL, RECTANGLE_VERTICAL_ONLY, RECTANGLE_HORIZONTAL_ONLY - } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + if (mLayoutWidth > 0 && mLayoutHeight > 0) { + // Gets original parameters, and creates the new parameters + val origParams = this.layoutParams + origParams.width = mLayoutWidth + origParams.height = mLayoutHeight + layoutParams = origParams + if (originalBitmap != null) { + applyImageMatrix( + (r - l).toFloat(), + (b - t).toFloat(), + center = true, + animate = false, + ) + // after state restore we want to restore the window crop, possible only after widget size + // is known + val restoreCropWindowRect = mRestoreCropWindowRect + if (restoreCropWindowRect != null) { + if (mRestoreDegreesRotated != mInitialDegreesRotated) { + mDegreesRotated = mRestoreDegreesRotated + applyImageMatrix( + width = (r - l).toFloat(), + height = (b - t).toFloat(), + center = true, + animate = false, + ) + mRestoreDegreesRotated = 0 + } + mImageMatrix.mapRect(mRestoreCropWindowRect) + mCropOverlayView?.cropWindowRect = restoreCropWindowRect + handleCropWindowChanged(inProgress = false, animate = false) + mCropOverlayView?.fixCurrentCropWindowRect() + mRestoreCropWindowRect = null + } else if (mSizeChanged) { + mSizeChanged = false + handleCropWindowChanged(inProgress = false, animate = false) + } + } else { + updateImageBounds(true) + } + } else { + updateImageBounds(true) + } + } - /** - * Possible crop corner shape - */ - enum class CropCornerShape { - RECTANGLE, OVAL - } + /** Detect size change to handle auto-zoom using [handleCropWindowChanged] in layout. */ + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + mSizeChanged = oldw > 0 && oldh > 0 + } - /** - * Options for scaling the bounds of cropping image to the bounds of Crop Image View.

- * Note: Some options are affected by auto-zoom, if enabled. - */ - enum class ScaleType { /** - * Scale the image uniformly (maintain the image's aspect ratio) to fit in crop image view.

- * The largest dimension will be equals to crop image view and the second dimension will be - * smaller. + * Handle crop window change to:

+ * 1. Execute auto-zoom-in/out depending on the area covered of cropping window relative to the + * available view area.

+ * 2. Slide the zoomed sub-area if the cropping window is outside the visible view sub-area. + *

+ * + * [inProgress] is the crop window change is still in progress by the user + * [animate] if to animate the change to the image matrix, or set it directly */ - FIT_CENTER, + private fun handleCropWindowChanged(inProgress: Boolean, animate: Boolean) { + + // 🔥 HARD STOP during manual zoom (prevents flicker) + if (isManualMode) return + + // 🔥 Prevent auto zoom immediately after manual + val now = System.currentTimeMillis() + if (now - lastManualEndTime < 150) return + + val width = width + val height = height + + if (originalBitmap != null && width > 0 && height > 0) { + + val cropRect = mCropOverlayView!!.cropWindowRect + + if (inProgress) { + if (cropRect.left < 0 || cropRect.top < 0 || + cropRect.right > width || cropRect.bottom > height + ) { + applyImageMatrix( + width = width.toFloat(), + height = height.toFloat(), + center = false, + animate = false, + ) + } + } else if (mAutoZoomEnabled || mZoom > 1) { + + var newZoom = mZoom // keep current zoom + + // ✅ ONLY auto zoom when NOT in manual mode + if (!isManualMode && mAutoZoomEnabled) { + + // Zoom IN + if (mZoom < mMaxZoom && + cropRect.width() < width * 0.5f && + cropRect.height() < height * 0.5f + ) { + newZoom = min( + mMaxZoom.toFloat(), + min( + width / (cropRect.width() / mZoom / 0.64f), + height / (cropRect.height() / mZoom / 0.64f), + ), + ) + } + + // Zoom OUT + if (mZoom > 1 && + (cropRect.width() > width * 0.65f || + cropRect.height() > height * 0.65f) + ) { + newZoom = max( + 1f, + min( + width / (cropRect.width() / mZoom / 0.51f), + height / (cropRect.height() / mZoom / 0.51f), + ), + ) + } + + if (!mAutoZoomEnabled) { + newZoom = 1f + } + } + + if (newZoom != mZoom) { + + if (animate) { + if (mAnimation == null) { + mAnimation = CropImageAnimation(imageView, mCropOverlayView) + } + mAnimation!!.setStartState(mImagePoints, mImageMatrix) + } + + mZoom = newZoom + + applyImageMatrix( + width.toFloat(), + height.toFloat(), + true, + animate + ) + } + } - /** - * Center the image in the view, but perform no scaling.

- * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it - * will be scaled uniformly to fit the crop image view. - */ - CENTER, + if (mOnSetCropWindowChangeListener != null && !inProgress) { + mOnSetCropWindowChangeListener!!.onCropWindowChanged() + } + } + } /** - * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width - * and height) of the image will be equal to or **larger** than the corresponding dimension - * of the view (minus padding).

- * The image is then centered in the view. + * Apply matrix to handle the image inside the image view. + * + * [width] the width of the image view + * [height] the height of the image view */ - CENTER_CROP, + private fun applyImageMatrix(width: Float, height: Float, center: Boolean, animate: Boolean) { - /** - * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width - * and height) of the image will be equal to or **less** than the corresponding dimension of - * the view (minus padding).

- * The image is then centered in the view.

- * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it - * will be scaled uniformly to fit the crop image view. - */ - CENTER_INSIDE, - } + // 🔥 HARD STOP during manual zoom (prevents flicker) + if (isManualMode) return - enum class Guidelines { - /** Never shown. */ - OFF, + val bitmap = originalBitmap + if (bitmap != null && width > 0 && height > 0) { - /** Shown when user is moving the crop window. */ - ON_TOUCH, + mImageMatrix.invert(mImageInverseMatrix) - /** Always shown. */ - ON, - } + val cropRect = mCropOverlayView!!.cropWindowRect + mImageInverseMatrix.mapRect(cropRect) - /** Possible options for handling requested width/height for cropping. */ - enum class RequestSizeOptions { - /** No resize/sampling is done unless required for memory management (OOM). */ - NONE, + mImageMatrix.reset() - /** - * Only sample the image during loading (if image set using URI) so the smallest of the image - * dimensions will be between the requested size and x2 requested size.

- * NOTE: resulting image will not be exactly requested width/height see: [Loading Large Bitmaps Efficiently](http://developer.android.com/training/displaying-bitmaps/load-bitmap.html). - */ - SAMPLING, + // Center image + mImageMatrix.postTranslate( + (width - bitmap.width) / 2, + (height - bitmap.height) / 2, + ) - /** - * Resize the image uniformly (maintain the image's aspect ratio) so that both dimensions (width - * and height) of the image will be equal to or **less** than the corresponding requested - * dimension.

- * If the image is smaller than the requested size it will NOT change. - */ - RESIZE_INSIDE, + mapImagePointsByImageMatrix() - /** - * Resize the image uniformly (maintain the image's aspect ratio) to fit in the given - * width/height.

- * The largest dimension will be equals to the requested and the second dimension will be - * smaller.

- * If the image is smaller than the requested size it will enlarge it. - */ - RESIZE_FIT, + // Rotation + if (mDegreesRotated > 0) { + mImageMatrix.postRotate( + mDegreesRotated.toFloat(), + BitmapUtils.getRectCenterX(mImagePoints), + BitmapUtils.getRectCenterY(mImagePoints), + ) + mapImagePointsByImageMatrix() + } - /** - * Resize the image to fit exactly in the given width/height.

- * This resize method does NOT preserve aspect ratio.

- * If the image is smaller than the requested size it will enlarge it. - */ - RESIZE_EXACT, - } + // Base scale + val scale = min( + width / BitmapUtils.getRectWidth(mImagePoints), + height / BitmapUtils.getRectHeight(mImagePoints), + ) - /** Interface definition for a callback to be invoked when the crop overlay is released. */ - fun interface OnSetCropOverlayReleasedListener { - /** - * Called when the crop overlay changed listener is called and inProgress is false. - * - * [rect] The rect coordinates of the cropped overlay - */ - fun onCropOverlayReleased(rect: Rect?) - } + if (mScaleType == ScaleType.FIT_CENTER || + (mScaleType == ScaleType.CENTER_INSIDE && scale < 1) || + (scale > 1 && mAutoZoomEnabled && !isManualMode) // ✅ FIXED + ) { + mImageMatrix.postScale( + scale, + scale, + BitmapUtils.getRectCenterX(mImagePoints), + BitmapUtils.getRectCenterY(mImagePoints), + ) + mapImagePointsByImageMatrix() + } else if (mScaleType == ScaleType.CENTER_CROP && !isManualMode) { + mZoom = max( + width / BitmapUtils.getRectWidth(mImagePoints), + height / BitmapUtils.getRectHeight(mImagePoints), + ) + } - /** Interface definition for a callback to be invoked when the crop overlay is released. */ - fun interface OnSetCropOverlayMovedListener { - /** - * Called when the crop overlay is moved - * - * [rect] The rect coordinates of the cropped overlay - */ - fun onCropOverlayMoved(rect: Rect?) - } + // Apply zoom + val scaleX = if (mFlipHorizontally) -mZoom else mZoom + val scaleY = if (mFlipVertically) -mZoom else mZoom - /** Interface definition for a callback to be invoked when the crop overlay is released. */ - fun interface OnSetCropWindowChangeListener { - /** Called when the crop window is changed */ - fun onCropWindowChanged() - } + mImageMatrix.postScale( + scaleX, + scaleY, + BitmapUtils.getRectCenterX(mImagePoints), + BitmapUtils.getRectCenterY(mImagePoints), + ) - /** Interface definition for a callback to be invoked when image async loading is complete. */ - fun interface OnSetImageUriCompleteListener { - /** - * Called when a crop image view has completed loading image for cropping.

- * If loading failed error parameter will contain the error. - * - * [view] The crop image view that loading of image was complete. - * [uri] the URI of the image that was loading - * [error] if error occurred during loading will contain the error, otherwise null. - */ - fun onSetImageUriComplete(view: CropImageView, uri: Uri, error: Exception?) - } + mapImagePointsByImageMatrix() + mImageMatrix.mapRect(cropRect) + + // Translation + if (mScaleType == ScaleType.CENTER_CROP && center && !animate) { + mZoomOffsetX = 0f + mZoomOffsetY = 0f + } else if (center) { + + mZoomOffsetX = + if (width > BitmapUtils.getRectWidth(mImagePoints)) { + 0f + } else { + max( + min( + width / 2 - cropRect.centerX(), + -BitmapUtils.getRectLeft(mImagePoints), + ), + width - BitmapUtils.getRectRight(mImagePoints), + ) / scaleX + } + + mZoomOffsetY = + if (height > BitmapUtils.getRectHeight(mImagePoints)) { + 0f + } else { + max( + min( + height / 2 - cropRect.centerY(), + -BitmapUtils.getRectTop(mImagePoints), + ), + height - BitmapUtils.getRectBottom(mImagePoints), + ) / scaleY + } + + } else { + + mZoomOffsetX = + min( + max(mZoomOffsetX * scaleX, -cropRect.left), + -cropRect.right + width, + ) / scaleX + + mZoomOffsetY = + min( + max(mZoomOffsetY * scaleY, -cropRect.top), + -cropRect.bottom + height, + ) / scaleY + } - /** Interface definition for a callback to be invoked when image async crop is complete. */ - fun interface OnCropImageCompleteListener { - /** - * Called when a crop image view has completed cropping image.

- * Result object contains the cropped bitmap, saved cropped image uri, crop points data or the - * error occurred during cropping. - * - * [view] The crop image view that cropping of image was complete. - * [result] the crop image result data (with cropped image or error) - */ - fun onCropImageComplete(view: CropImageView, result: CropResult) - } + mImageMatrix.postTranslate(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY) + cropRect.offset(mZoomOffsetX * scaleX, mZoomOffsetY * scaleY) + + mCropOverlayView.cropWindowRect = cropRect + + mapImagePointsByImageMatrix() + mCropOverlayView.invalidate() + + if (animate) { + mAnimation!!.setEndState(mImagePoints, mImageMatrix) + imageView.startAnimation(mAnimation) + } else { + if (!isManualMode) { + imageView.imageMatrix = mImageMatrix + } + } + + updateImageBounds(false) + } + } + + fun beginManualMatrixMode() { + if (!isManualMode) { + isManualMode = true + } + + if (!isMatrixSynced) { + isMatrixSynced = true + imageView.setExternalMatrix(mImageMatrix) + } + } + + fun getCropBoundsForTouchImage(): RectF { + return RectF(mCropOverlayView!!.cropWindowRect) + } + + fun endManualMatrixMode(matrix: Matrix) { + lastManualEndTime = System.currentTimeMillis() + + mImageMatrix.set(matrix) + imageView.imageMatrix = mImageMatrix + + mapImagePointsByImageMatrix() + updateImageBounds(false) + + mCropOverlayView?.invalidate() + invalidate() + + isMatrixSynced = false + + // Important: + // Do NOT set isManualMode = false here. + // Once user manually zooms/pans, CropImageView should not auto-fight the matrix. + } - /** Result data of crop image. */ - open class CropResult internal constructor( - /** - * The image bitmap of the original image loaded for cropping.

- * Null if uri used to load image or activity result is used. - */ - val originalBitmap: Bitmap?, /** - * The Android uri of the original image loaded for cropping.

- * Null if bitmap was used to load image. + * Adjust the given image rectangle by image transformation matrix to know the final rectangle of + * the image.

+ * To get the proper rectangle it must be first reset to original image rectangle. */ - val originalUri: Uri?, + private fun mapImagePointsByImageMatrix() { + mImagePoints[0] = 0f + mImagePoints[1] = 0f + mImagePoints[2] = originalBitmap!!.width.toFloat() + mImagePoints[3] = 0f + mImagePoints[4] = originalBitmap!!.width.toFloat() + mImagePoints[5] = originalBitmap!!.height.toFloat() + mImagePoints[6] = 0f + mImagePoints[7] = originalBitmap!!.height.toFloat() + mImageMatrix.mapPoints(mImagePoints) + mScaleImagePoints[0] = 0f + mScaleImagePoints[1] = 0f + mScaleImagePoints[2] = 100f + mScaleImagePoints[3] = 0f + mScaleImagePoints[4] = 100f + mScaleImagePoints[5] = 100f + mScaleImagePoints[6] = 0f + mScaleImagePoints[7] = 100f + mImageMatrix.mapPoints(mScaleImagePoints) + } + /** - * The cropped image bitmap result.

- * Null if save cropped image was executed, no output requested or failure. + * Set visibility of crop overlay to hide it when there is no image or specifically set by client. */ - val bitmap: Bitmap?, + private fun setCropOverlayVisibility() { + if (mCropOverlayView != null) { + mCropOverlayView.visibility = + if (mShowCropOverlay && originalBitmap != null) VISIBLE else INVISIBLE + } + } + /** - * The Android uri of the saved cropped image result.

- * Null if get cropped image was executed, no output requested or failure. - * - * This is NOT the file path, please use [getUriFilePath] + * Set visibility of progress bar when async loading/cropping is in process and show is enabled. */ - val uriContent: Uri?, - /** The error that failed the loading/cropping (null if successful). */ - val error: Exception?, - /** The 4 points of the cropping window in the source image. */ - val cropPoints: FloatArray, - /** The rectangle of the cropping window in the source image. */ - val cropRect: Rect?, - /** The rectangle of the source image dimensions. */ - val wholeImageRect: Rect?, - /** The final rotation of the cropped image relative to source. */ - val rotation: Int, - /** Sample size used creating the crop bitmap to lower its size. */ - val sampleSize: Int, - ) { - val isSuccessful: Boolean - get() = error == null + private fun setProgressBarVisibility() { + val visible = ( + mShowProgressBar && + ( + originalBitmap == null && bitmapLoadingWorkerJob != null || + bitmapCroppingWorkerJob != null + ) + ) + mProgressBar.visibility = + if (visible) VISIBLE else INVISIBLE + } + + /** Update the scale factor between the actual image bitmap and the shown image.

*/ + private fun updateImageBounds(clear: Boolean) { + if (originalBitmap != null && !clear) { + // Get the scale factor between the actual Bitmap dimensions and the displayed dimensions for + // width/height. + val scaleFactorWidth = + 100f * loadedSampleSize / BitmapUtils.getRectWidth(mScaleImagePoints) + val scaleFactorHeight = + 100f * loadedSampleSize / BitmapUtils.getRectHeight(mScaleImagePoints) + mCropOverlayView!!.setCropWindowLimits( + width.toFloat(), + height.toFloat(), + scaleFactorWidth, + scaleFactorHeight, + ) + } + // set the bitmap rectangle and update the crop window after scale factor is set + mCropOverlayView!!.setBounds(if (clear) null else mImagePoints, width, height) + } /** - * The cropped image bitmap result.

- * Null if save cropped image was executed, no output requested or failure. - * - * [context] used to retrieve the bitmap in case you need from activity result + * The possible cropping area shape.

+ * To set square/circle crop shape set aspect ratio to 1:1. */ - fun getBitmap(context: Context): Bitmap? { - return bitmap ?: try { - when { - SDK_INT >= 28 -> ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uriContent!!)) - else -> @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(context.contentResolver, uriContent) - } - } catch (e: Exception) { - null - } + enum class CropShape { + RECTANGLE, OVAL, RECTANGLE_VERTICAL_ONLY, RECTANGLE_HORIZONTAL_ONLY } /** - * The file path of the image to load - * Null if you get cropped image was executed, no output requested or failure. - * - * [context] used to access Android APIs, like content resolve, it is your activity/fragment/widget. - * [uniqueName] If true, make each image cropped have a different file name, this could - * cause memory issues, use wisely. + * Possible crop corner shape */ - fun getUriFilePath(context: Context, uniqueName: Boolean = false): String? = - uriContent?.let { getFilePathFromUri(context, it, uniqueName) } - } + enum class CropCornerShape { + RECTANGLE, OVAL + } - internal companion object { /** - * Determines the specs for the onMeasure function. Calculates the width or height depending on - * the mode. - * - * [measureSpecMode] The mode of the measured width or height. - * [measureSpecSize] The size of the measured width or height. - * [desiredSize] The desired size of the measured width or height. - * @return The final size of the width or height. + * Options for scaling the bounds of cropping image to the bounds of Crop Image View.

+ * Note: Some options are affected by auto-zoom, if enabled. */ - internal fun getOnMeasureSpec( - measureSpecMode: Int, - measureSpecSize: Int, - desiredSize: Int, - ): Int { - // Measure Width - return when (measureSpecMode) { - MeasureSpec.EXACTLY -> measureSpecSize // Must be this size - MeasureSpec.AT_MOST -> min( - desiredSize, - measureSpecSize, - ) // Can't be bigger than...; match_parent value - else -> desiredSize // Be whatever you want; wrap_content - } + enum class ScaleType { + /** + * Scale the image uniformly (maintain the image's aspect ratio) to fit in crop image view.

+ * The largest dimension will be equals to crop image view and the second dimension will be + * smaller. + */ + FIT_CENTER, + + /** + * Center the image in the view, but perform no scaling.

+ * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it + * will be scaled uniformly to fit the crop image view. + */ + CENTER, + + /** + * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width + * and height) of the image will be equal to or **larger** than the corresponding dimension + * of the view (minus padding).

+ * The image is then centered in the view. + */ + CENTER_CROP, + + /** + * Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width + * and height) of the image will be equal to or **less** than the corresponding dimension of + * the view (minus padding).

+ * The image is then centered in the view.

+ * Note: If auto-zoom is enabled and the source image is smaller than crop image view then it + * will be scaled uniformly to fit the crop image view. + */ + CENTER_INSIDE, } - } - init { - val options = (context as? Activity)?.intent?.getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE)?.parcelable( - CropImage.CROP_IMAGE_EXTRA_OPTIONS) - ?: if (attrs != null) { - val a = context.obtainStyledAttributes(attrs, R.styleable.CropImageView, 0, 0) - val default = CropImageOptions() - try { - @Suppress("DEPRECATION") - isSaveBitmapToInstanceState = a.getBoolean(R.styleable.CropImageView_ls_cropSaveBitmapToInstanceState, isSaveBitmapToInstanceState) - - CropImageOptions( - scaleType = ScaleType.values()[a.getInt(R.styleable.CropImageView_ls_cropScaleType, default.scaleType.ordinal)], - cropShape = CropShape.values()[a.getInt(R.styleable.CropImageView_ls_cropShape, default.cropShape.ordinal)], - cornerShape = CropCornerShape.values()[a.getInt(R.styleable.CropImageView_ls_cornerShape, default.cornerShape.ordinal)], - guidelines = Guidelines.values()[a.getInt(R.styleable.CropImageView_ls_cropGuidelines, default.guidelines.ordinal)], - aspectRatioX = a.getInteger(R.styleable.CropImageView_ls_cropAspectRatioX, default.aspectRatioX), - aspectRatioY = a.getInteger(R.styleable.CropImageView_ls_cropAspectRatioY, default.aspectRatioY), - autoZoomEnabled = a.getBoolean(R.styleable.CropImageView_ls_cropAutoZoomEnabled, default.autoZoomEnabled), - multiTouchEnabled = a.getBoolean(R.styleable.CropImageView_ls_cropMultiTouchEnabled, default.multiTouchEnabled), - centerMoveEnabled = a.getBoolean(R.styleable.CropImageView_ls_cropCenterMoveEnabled, default.centerMoveEnabled), - cropCornerRadius = a.getDimension(R.styleable.CropImageView_ls_cropCornerRadius, default.cropCornerRadius), - snapRadius = a.getDimension(R.styleable.CropImageView_ls_cropSnapRadius, default.snapRadius), - touchRadius = a.getDimension(R.styleable.CropImageView_ls_cropTouchRadius, default.touchRadius), - initialCropWindowPaddingRatio = a.getFloat(R.styleable.CropImageView_ls_cropInitialCropWindowPaddingRatio, default.initialCropWindowPaddingRatio), - circleCornerFillColorHexValue = a.getInteger(R.styleable.CropImageView_ls_cropCornerCircleFillColor, default.circleCornerFillColorHexValue), - borderLineThickness = a.getDimension(R.styleable.CropImageView_ls_cropBorderLineThickness, default.borderLineThickness), - borderLineColor = a.getInteger(R.styleable.CropImageView_ls_cropBorderLineColor, default.borderLineColor), - borderCornerThickness = a.getDimension(R.styleable.CropImageView_ls_cropBorderCornerThickness, default.borderCornerThickness), - borderCornerOffset = a.getDimension(R.styleable.CropImageView_ls_cropBorderCornerOffset, default.borderCornerOffset), - borderCornerLength = a.getDimension(R.styleable.CropImageView_ls_cropBorderCornerLength, default.borderCornerLength), - borderCornerColor = a.getInteger(R.styleable.CropImageView_ls_cropBorderCornerColor, default.borderCornerColor), - guidelinesThickness = a.getDimension(R.styleable.CropImageView_ls_cropGuidelinesThickness, default.guidelinesThickness), - guidelinesColor = a.getInteger(R.styleable.CropImageView_ls_cropGuidelinesColor, default.guidelinesColor), - backgroundColor = a.getInteger(R.styleable.CropImageView_ls_cropBackgroundColor, default.backgroundColor), - minCropWindowWidth = a.getDimension(R.styleable.CropImageView_ls_cropMinCropWindowWidth, default.minCropWindowWidth.toFloat()).toInt(), - minCropWindowHeight = a.getDimension(R.styleable.CropImageView_ls_cropMinCropWindowHeight, default.minCropWindowHeight.toFloat()).toInt(), - minCropResultWidth = a.getFloat(R.styleable.CropImageView_ls_cropMinCropResultWidthPX, default.minCropResultWidth.toFloat()).toInt(), - minCropResultHeight = a.getFloat(R.styleable.CropImageView_ls_cropMinCropResultHeightPX, default.minCropResultHeight.toFloat()).toInt(), - maxCropResultWidth = a.getFloat(R.styleable.CropImageView_ls_cropMaxCropResultWidthPX, default.maxCropResultWidth.toFloat()).toInt(), - maxCropResultHeight = a.getFloat(R.styleable.CropImageView_ls_cropMaxCropResultHeightPX, default.maxCropResultHeight.toFloat()).toInt(), - flipHorizontally = a.getBoolean(R.styleable.CropImageView_ls_cropFlipHorizontally, default.flipHorizontally), - flipVertically = a.getBoolean(R.styleable.CropImageView_ls_cropFlipHorizontally, default.flipVertically), - cropperLabelTextSize = a.getDimension(R.styleable.CropImageView_ls_cropperLabelTextSize, default.cropperLabelTextSize), - cropperLabelTextColor = a.getInteger(R.styleable.CropImageView_ls_cropperLabelTextColor, default.cropperLabelTextColor), - showCropLabel = a.getBoolean(R.styleable.CropImageView_ls_cropShowLabel, default.showCropLabel), - maxZoom = a.getInteger(R.styleable.CropImageView_ls_cropMaxZoom, default.maxZoom), - showCropOverlay = a.getBoolean(R.styleable.CropImageView_ls_cropShowCropOverlay, default.showCropOverlay), - showProgressBar = a.getBoolean(R.styleable.CropImageView_ls_cropShowProgressBar, default.showProgressBar), - cropperLabelText = a.getString(R.styleable.CropImageView_ls_cropperLabelText), - fixAspectRatio = a.getBoolean(R.styleable.CropImageView_ls_cropFixAspectRatio, default.fixAspectRatio) || a.hasValue(R.styleable.CropImageView_ls_cropAspectRatioX) && a.hasValue(R.styleable.CropImageView_ls_cropAspectRatioX), - ) - } finally { - a.recycle() + enum class Guidelines { + /** Never shown. */ + OFF, + + /** Shown when user is moving the crop window. */ + ON_TOUCH, + + /** Always shown. */ + ON, + } + + /** Possible options for handling requested width/height for cropping. */ + enum class RequestSizeOptions { + /** No resize/sampling is done unless required for memory management (OOM). */ + NONE, + + /** + * Only sample the image during loading (if image set using URI) so the smallest of the image + * dimensions will be between the requested size and x2 requested size.

+ * NOTE: resulting image will not be exactly requested width/height see: [Loading Large Bitmaps Efficiently](http://developer.android.com/training/displaying-bitmaps/load-bitmap.html). + */ + SAMPLING, + + /** + * Resize the image uniformly (maintain the image's aspect ratio) so that both dimensions (width + * and height) of the image will be equal to or **less** than the corresponding requested + * dimension.

+ * If the image is smaller than the requested size it will NOT change. + */ + RESIZE_INSIDE, + + /** + * Resize the image uniformly (maintain the image's aspect ratio) to fit in the given + * width/height.

+ * The largest dimension will be equals to the requested and the second dimension will be + * smaller.

+ * If the image is smaller than the requested size it will enlarge it. + */ + RESIZE_FIT, + + /** + * Resize the image to fit exactly in the given width/height.

+ * This resize method does NOT preserve aspect ratio.

+ * If the image is smaller than the requested size it will enlarge it. + */ + RESIZE_EXACT, + } + + /** Interface definition for a callback to be invoked when the crop overlay is released. */ + fun interface OnSetCropOverlayReleasedListener { + /** + * Called when the crop overlay changed listener is called and inProgress is false. + * + * [rect] The rect coordinates of the cropped overlay + */ + fun onCropOverlayReleased(rect: Rect?) + } + + /** Interface definition for a callback to be invoked when the crop overlay is released. */ + fun interface OnSetCropOverlayMovedListener { + /** + * Called when the crop overlay is moved + * + * [rect] The rect coordinates of the cropped overlay + */ + fun onCropOverlayMoved(rect: Rect?) + } + + /** Interface definition for a callback to be invoked when the crop overlay is released. */ + fun interface OnSetCropWindowChangeListener { + /** Called when the crop window is changed */ + fun onCropWindowChanged() + } + + /** Interface definition for a callback to be invoked when image async loading is complete. */ + fun interface OnSetImageUriCompleteListener { + /** + * Called when a crop image view has completed loading image for cropping.

+ * If loading failed error parameter will contain the error. + * + * [view] The crop image view that loading of image was complete. + * [uri] the URI of the image that was loading + * [error] if error occurred during loading will contain the error, otherwise null. + */ + fun onSetImageUriComplete(view: CropImageView, uri: Uri, error: Exception?) + } + + /** Interface definition for a callback to be invoked when image async crop is complete. */ + fun interface OnCropImageCompleteListener { + /** + * Called when a crop image view has completed cropping image.

+ * Result object contains the cropped bitmap, saved cropped image uri, crop points data or the + * error occurred during cropping. + * + * [view] The crop image view that cropping of image was complete. + * [result] the crop image result data (with cropped image or error) + */ + fun onCropImageComplete(view: CropImageView, result: CropResult) + } + + /** Result data of crop image. */ + open class CropResult internal constructor( + /** + * The image bitmap of the original image loaded for cropping.

+ * Null if uri used to load image or activity result is used. + */ + val originalBitmap: Bitmap?, + /** + * The Android uri of the original image loaded for cropping.

+ * Null if bitmap was used to load image. + */ + val originalUri: Uri?, + /** + * The cropped image bitmap result.

+ * Null if save cropped image was executed, no output requested or failure. + */ + var bitmap: Bitmap?, + /** + * The Android uri of the saved cropped image result.

+ * Null if get cropped image was executed, no output requested or failure. + * + * This is NOT the file path, please use [getUriFilePath] + */ + val uriContent: Uri?, + /** The error that failed the loading/cropping (null if successful). */ + val error: Exception?, + /** The 4 points of the cropping window in the source image. */ + val cropPoints: FloatArray, + /** The rectangle of the cropping window in the source image. */ + val cropRect: Rect?, + /** The rectangle of the source image dimensions. */ + val wholeImageRect: Rect?, + /** The final rotation of the cropped image relative to source. */ + val rotation: Int, + /** Sample size used creating the crop bitmap to lower its size. */ + val sampleSize: Int, + ) { + val isSuccessful: Boolean + get() = error == null + + /** + * The cropped image bitmap result.

+ * Null if save cropped image was executed, no output requested or failure. + * + * [context] used to retrieve the bitmap in case you need from activity result + */ + fun getBitmap(context: Context): Bitmap? { + return bitmap ?: try { + when { + SDK_INT >= 28 -> ImageDecoder.decodeBitmap( + ImageDecoder.createSource( + context.contentResolver, + uriContent!! + ) + ) + + else -> @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap( + context.contentResolver, + uriContent + ) + } + } catch (e: Exception) { + null + } + } + + /** + * The file path of the image to load + * Null if you get cropped image was executed, no output requested or failure. + * + * [context] used to access Android APIs, like content resolve, it is your activity/fragment/widget. + * [uniqueName] If true, make each image cropped have a different file name, this could + * cause memory issues, use wisely. + */ + fun getUriFilePath(context: Context, uniqueName: Boolean = false): String? = + uriContent?.let { getFilePathFromUri(context, it, uniqueName) } + } + + internal companion object { + /** + * Determines the specs for the onMeasure function. Calculates the width or height depending on + * the mode. + * + * [measureSpecMode] The mode of the measured width or height. + * [measureSpecSize] The size of the measured width or height. + * [desiredSize] The desired size of the measured width or height. + * @return The final size of the width or height. + */ + internal fun getOnMeasureSpec( + measureSpecMode: Int, + measureSpecSize: Int, + desiredSize: Int, + ): Int { + // Measure Width + return when (measureSpecMode) { + MeasureSpec.EXACTLY -> measureSpecSize // Must be this size + MeasureSpec.AT_MOST -> min( + desiredSize, + measureSpecSize, + ) // Can't be bigger than...; match_parent value + else -> desiredSize // Be whatever you want; wrap_content + } } - } else { - CropImageOptions() - } - - mScaleType = options.scaleType - mAutoZoomEnabled = options.autoZoomEnabled - mMaxZoom = options.maxZoom - mCropLabelTextSize = options.cropperLabelTextSize - mShowCropLabel = options.showCropLabel - mShowCropOverlay = options.showCropOverlay - mShowProgressBar = options.showProgressBar - mFlipHorizontally = options.flipHorizontally - mFlipVertically = options.flipVertically - val inflater = LayoutInflater.from(context) - val v = inflater.inflate(R.layout.crop_image_view, this, true) - imageView = v.findViewById(R.id.ImageView_image) - imageView.scaleType = ImageView.ScaleType.MATRIX - mCropOverlayView = v.findViewById(R.id.CropOverlayView) - mCropOverlayView.setCropWindowChangeListener(this) - mCropOverlayView.setInitialAttributeValues(options) - mProgressBar = v.findViewById(R.id.CropProgressBar) - mProgressBar.indeterminateTintList = ColorStateList.valueOf(options.progressBarColor) - setProgressBarVisibility() - - // Gives the text of the status bar dark color + } + + init { + val options = + (context as? Activity)?.intent?.getBundleExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE) + ?.parcelable( + CropImage.CROP_IMAGE_EXTRA_OPTIONS + ) + ?: if (attrs != null) { + val a = context.obtainStyledAttributes(attrs, R.styleable.CropImageView, 0, 0) + val default = CropImageOptions() + try { + @Suppress("DEPRECATION") + isSaveBitmapToInstanceState = a.getBoolean( + R.styleable.CropImageView_ls_cropSaveBitmapToInstanceState, + isSaveBitmapToInstanceState + ) + + CropImageOptions( + scaleType = ScaleType.values()[a.getInt( + R.styleable.CropImageView_ls_cropScaleType, + default.scaleType.ordinal + )], + cropShape = CropShape.values()[a.getInt( + R.styleable.CropImageView_ls_cropShape, + default.cropShape.ordinal + )], + cornerShape = CropCornerShape.values()[a.getInt( + R.styleable.CropImageView_ls_cornerShape, + default.cornerShape.ordinal + )], + guidelines = Guidelines.values()[a.getInt( + R.styleable.CropImageView_ls_cropGuidelines, + default.guidelines.ordinal + )], + aspectRatioX = a.getInteger( + R.styleable.CropImageView_ls_cropAspectRatioX, + default.aspectRatioX + ), + aspectRatioY = a.getInteger( + R.styleable.CropImageView_ls_cropAspectRatioY, + default.aspectRatioY + ), + autoZoomEnabled = a.getBoolean( + R.styleable.CropImageView_ls_cropAutoZoomEnabled, + default.autoZoomEnabled + ), + multiTouchEnabled = a.getBoolean( + R.styleable.CropImageView_ls_cropMultiTouchEnabled, + default.multiTouchEnabled + ), + centerMoveEnabled = a.getBoolean( + R.styleable.CropImageView_ls_cropCenterMoveEnabled, + default.centerMoveEnabled + ), + cropCornerRadius = a.getDimension( + R.styleable.CropImageView_ls_cropCornerRadius, + default.cropCornerRadius + ), + snapRadius = a.getDimension( + R.styleable.CropImageView_ls_cropSnapRadius, + default.snapRadius + ), + touchRadius = a.getDimension( + R.styleable.CropImageView_ls_cropTouchRadius, + default.touchRadius + ), + initialCropWindowPaddingRatio = a.getFloat( + R.styleable.CropImageView_ls_cropInitialCropWindowPaddingRatio, + default.initialCropWindowPaddingRatio + ), + circleCornerFillColorHexValue = a.getInteger( + R.styleable.CropImageView_ls_cropCornerCircleFillColor, + default.circleCornerFillColorHexValue + ), + borderLineThickness = a.getDimension( + R.styleable.CropImageView_ls_cropBorderLineThickness, + default.borderLineThickness + ), + borderLineColor = a.getInteger( + R.styleable.CropImageView_ls_cropBorderLineColor, + default.borderLineColor + ), + borderCornerThickness = a.getDimension( + R.styleable.CropImageView_ls_cropBorderCornerThickness, + default.borderCornerThickness + ), + borderCornerOffset = a.getDimension( + R.styleable.CropImageView_ls_cropBorderCornerOffset, + default.borderCornerOffset + ), + borderCornerLength = a.getDimension( + R.styleable.CropImageView_ls_cropBorderCornerLength, + default.borderCornerLength + ), + borderCornerColor = a.getInteger( + R.styleable.CropImageView_ls_cropBorderCornerColor, + default.borderCornerColor + ), + guidelinesThickness = a.getDimension( + R.styleable.CropImageView_ls_cropGuidelinesThickness, + default.guidelinesThickness + ), + guidelinesColor = a.getInteger( + R.styleable.CropImageView_ls_cropGuidelinesColor, + default.guidelinesColor + ), + backgroundColor = a.getInteger( + R.styleable.CropImageView_ls_cropBackgroundColor, + default.backgroundColor + ), + minCropWindowWidth = a.getDimension( + R.styleable.CropImageView_ls_cropMinCropWindowWidth, + default.minCropWindowWidth.toFloat() + ).toInt(), + minCropWindowHeight = a.getDimension( + R.styleable.CropImageView_ls_cropMinCropWindowHeight, + default.minCropWindowHeight.toFloat() + ).toInt(), + minCropResultWidth = a.getFloat( + R.styleable.CropImageView_ls_cropMinCropResultWidthPX, + default.minCropResultWidth.toFloat() + ).toInt(), + minCropResultHeight = a.getFloat( + R.styleable.CropImageView_ls_cropMinCropResultHeightPX, + default.minCropResultHeight.toFloat() + ).toInt(), + maxCropResultWidth = a.getFloat( + R.styleable.CropImageView_ls_cropMaxCropResultWidthPX, + default.maxCropResultWidth.toFloat() + ).toInt(), + maxCropResultHeight = a.getFloat( + R.styleable.CropImageView_ls_cropMaxCropResultHeightPX, + default.maxCropResultHeight.toFloat() + ).toInt(), + flipHorizontally = a.getBoolean( + R.styleable.CropImageView_ls_cropFlipHorizontally, + default.flipHorizontally + ), + flipVertically = a.getBoolean( + R.styleable.CropImageView_ls_cropFlipHorizontally, + default.flipVertically + ), + cropperLabelTextSize = a.getDimension( + R.styleable.CropImageView_ls_cropperLabelTextSize, + default.cropperLabelTextSize + ), + cropperLabelTextColor = a.getInteger( + R.styleable.CropImageView_ls_cropperLabelTextColor, + default.cropperLabelTextColor + ), + showCropLabel = a.getBoolean( + R.styleable.CropImageView_ls_cropShowLabel, + default.showCropLabel + ), + maxZoom = a.getInteger( + R.styleable.CropImageView_ls_cropMaxZoom, + default.maxZoom + ), + showCropOverlay = a.getBoolean( + R.styleable.CropImageView_ls_cropShowCropOverlay, + default.showCropOverlay + ), + showProgressBar = a.getBoolean( + R.styleable.CropImageView_ls_cropShowProgressBar, + default.showProgressBar + ), + cropperLabelText = a.getString(R.styleable.CropImageView_ls_cropperLabelText), + fixAspectRatio = a.getBoolean( + R.styleable.CropImageView_ls_cropFixAspectRatio, + default.fixAspectRatio + ) || a.hasValue(R.styleable.CropImageView_ls_cropAspectRatioX) && a.hasValue( + R.styleable.CropImageView_ls_cropAspectRatioX + ), + ) + } finally { + a.recycle() + } + } else { + CropImageOptions() + } + + mScaleType = options.scaleType + mAutoZoomEnabled = options.autoZoomEnabled + mMaxZoom = options.maxZoom + mCropLabelTextSize = options.cropperLabelTextSize + mShowCropLabel = options.showCropLabel + mShowCropOverlay = options.showCropOverlay + mShowProgressBar = options.showProgressBar + mFlipHorizontally = options.flipHorizontally + mFlipVertically = options.flipVertically + val inflater = LayoutInflater.from(context) + val v = inflater.inflate(R.layout.crop_image_view, this, true) + imageView = v.findViewById(R.id.ImageView_image) + imageView.scaleType = ImageView.ScaleType.MATRIX + mCropOverlayView = v.findViewById(R.id.CropOverlayView) + mCropOverlayView.setCropWindowChangeListener(this) + mCropOverlayView.setInitialAttributeValues(options) + mProgressBar = v.findViewById(R.id.CropProgressBar) + mProgressBar.indeterminateTintList = ColorStateList.valueOf(options.progressBarColor) + setProgressBarVisibility() + // Gives the text of the status bar dark color (context as? Activity)?.let { activity -> WindowInsetsControllerCompat(activity.window, activity.window.decorView) .isAppearanceLightStatusBars = true @@ -1908,13 +2138,13 @@ class CropImageView @JvmOverloads constructor( } } - override fun onCropWindowChanged(inProgress: Boolean) { - handleCropWindowChanged(inProgress, true) + override fun onCropWindowChanged(inProgress: Boolean) { + handleCropWindowChanged(inProgress, true) - if (inProgress) { - mOnSetCropOverlayMovedListener?.onCropOverlayMoved(cropRect) - } else { - mOnCropOverlayReleasedListener?.onCropOverlayReleased(cropRect) + if (inProgress) { + mOnSetCropOverlayMovedListener?.onCropOverlayMoved(cropRect) + } else { + mOnCropOverlayReleasedListener?.onCropOverlayReleased(cropRect) + } } - } } diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/CropOverlayView.kt b/lassi/src/main/java/com/lassi/presentation/cropper/CropOverlayView.kt index 046b166..92723e0 100644 --- a/lassi/src/main/java/com/lassi/presentation/cropper/CropOverlayView.kt +++ b/lassi/src/main/java/com/lassi/presentation/cropper/CropOverlayView.kt @@ -13,6 +13,7 @@ import android.graphics.Region import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.AttributeSet +import android.util.Log import android.util.TypedValue import android.view.MotionEvent import android.view.ScaleGestureDetector @@ -26,6 +27,7 @@ import kotlin.math.abs import kotlin.math.acos import kotlin.math.asin import kotlin.math.cos +import kotlin.math.hypot import kotlin.math.max import kotlin.math.min import kotlin.math.sin @@ -1090,28 +1092,42 @@ internal class CropOverlayView @JvmOverloads constructor( @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { // If this View is not enabled, don't allow for touch interactions. - return if (isEnabled) { - if (mMultiTouchEnabled) mScaleDetector?.onTouchEvent(event) - when (event.action) { - MotionEvent.ACTION_DOWN -> { - onActionDown(event.x, event.y) - true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - parent.requestDisallowInterceptTouchEvent(false) - onActionUp() - true + // the below code block is for manual zooming in the crop-shape area... + val cropRect = mCropWindowHandler.getRect() + val centerX = cropRect.centerX() + val centerY = cropRect.centerY() + val radius = cropRect.width().div(2f) // Assuming it's a circle + val distance = hypot(event.x - centerX, event.y - centerY) + + if (distance < radius){ + return false // this thing disables the touch in the specified area. + } else { + return if (isEnabled) { + if (mMultiTouchEnabled) { + mScaleDetector?.onTouchEvent(event) } - MotionEvent.ACTION_MOVE -> { - onActionMove(event.x, event.y) - parent.requestDisallowInterceptTouchEvent(true) - true + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + onActionDown(event.x, event.y) + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + parent.requestDisallowInterceptTouchEvent(false) + onActionUp() + true + } + MotionEvent.ACTION_MOVE -> { + onActionMove(event.x, event.y) + parent.requestDisallowInterceptTouchEvent(true) + true + } + else -> false } - else -> false + } else { + false } - } else { - false } } diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/TouchImageView.kt b/lassi/src/main/java/com/lassi/presentation/cropper/TouchImageView.kt new file mode 100644 index 0000000..6bfc687 --- /dev/null +++ b/lassi/src/main/java/com/lassi/presentation/cropper/TouchImageView.kt @@ -0,0 +1,456 @@ +package com.lassi.presentation.cropper + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import androidx.appcompat.widget.AppCompatImageView +import androidx.exifinterface.media.ExifInterface +import kotlin.math.max +import kotlin.math.min +import androidx.core.graphics.createBitmap +import kotlin.math.abs + +@SuppressLint("ClickableViewAccessibility") +class TouchImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) { + + private val baseMatrix = Matrix() // this is the initial matrix + private val gestureMatrix = Matrix() // this is for tracking the movement + private val drawMatrix = Matrix() // this is for the drawing of the image + private var mode = NONE + + private val last = PointF() + private val start = PointF() + private var minScale = 1f + private var maxScale = 4f + + private var viewWidth = 0 + private var viewHeight = 0 + private var saveScale = 1f + private var origWidth = 0f + private var origHeight = 0f + + private val mScaleDetector: ScaleGestureDetector + + private var originalBitmap: Bitmap? = null + private var originalUri: Uri? = null + private var exifAngle: Float = 0f + private var rotatedDegrees = 0 + + var flipHorizontally = false + var flipVertically = false + + private var isScaling = false + + private var scalePivotX = 0f + private var scalePivotY = 0f + + fun flipImageHorizontally() { + flipHorizontally = !flipHorizontally + fitImageToView() + } + + fun flipImageVertically() { + flipVertically = !flipVertically + fitImageToView() + } + + fun setOriginalBitmap(bitmap: Bitmap?) { + originalBitmap = bitmap + } + + fun setOriginalUri(uri: Uri?) { + originalUri = uri + uri?.let { + exifAngle = extractExifRotation(it) + } + } + + init { + super.setClickable(true) + mScaleDetector = ScaleGestureDetector(context, ScaleListener()) + scaleType = ScaleType.MATRIX + + setOnTouchListener { _, event -> + + val parentView = findCropImageView() + + // 🔥 Always pass to scale detector FIRST + mScaleDetector.onTouchEvent(event) + + // 🔥 Detect pinch EARLY (IMPORTANT FIX) + if (event.pointerCount >= 2) { + if (parentView != null && !parentView.isManualMode) { + + parentView.isManualMode = true + parentView.isMatrixSynced = true + + // ✅ Sync Crop → Touch only once when entering manual mode + setExternalMatrix(parentView.mImageMatrix) + } + } + + when (event.actionMasked) { + + MotionEvent.ACTION_DOWN -> { + last.set(event.x, event.y) + start.set(last) + mode = DRAG + } + + MotionEvent.ACTION_MOVE -> { + // ✅ ONLY DRAG when single finger + if (mode == DRAG && event.pointerCount == 1) { + + val dx = event.x - last.x + val dy = event.y - last.y + + drawMatrix.postTranslate(dx, dy) + fixBounds() + + imageMatrix = drawMatrix + invalidate() + + last.set(event.x, event.y) + } + } + + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + + mode = NONE + + parentView?.postDelayed({ + + if (parentView.isManualMode) { + + parentView.lastManualEndTime = System.currentTimeMillis() + + val matrix = getCurrentMatrix() + + parentView.mImageMatrix.set(matrix) + parentView.imageView.imageMatrix = parentView.mImageMatrix + + parentView.invalidate() + } + + }, 150) + } + } + + // 🔥 Apply matrix + updateDrawMatrix() + invalidate() + + true + } + } + + private fun findCropImageView(): CropImageView? { + var viewParent: android.view.ViewParent? = this@TouchImageView.parent + + while (viewParent != null) { + if (viewParent is CropImageView) { + return viewParent + } + viewParent = viewParent.parent + } + + return null + } + + private fun updateDrawMatrix() { + imageMatrix = drawMatrix + } + + fun getCurrentMatrix(): Matrix { + return Matrix(drawMatrix) + } + + fun setExternalMatrix(externalMatrix: Matrix) { + drawMatrix.set(externalMatrix) + imageMatrix = drawMatrix + invalidate() + } + + private fun extractExifRotation(uri: Uri): Float { + val exif = ExifInterface(context.contentResolver.openInputStream(uri)!!) + return when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + else -> 0f + } + } + fun getScale(): Float { + val values = FloatArray(9) + drawMatrix.getValues(values) + return values[Matrix.MSCALE_X] + } + + fun getTranslation(): Pair { + val values = FloatArray(9) + drawMatrix.getValues(values) + return Pair(values[Matrix.MTRANS_X], values[Matrix.MTRANS_Y]) + } + + + private fun fixTrans() { + val drawable = drawable ?: return + + val matrix = Matrix() + matrix.set(baseMatrix) + matrix.postConcat(gestureMatrix) + + val drawableWidth = drawable.intrinsicWidth.toFloat() + val drawableHeight = drawable.intrinsicHeight.toFloat() + + val points = floatArrayOf( + 0f, 0f, + drawableWidth, 0f, + drawableWidth, drawableHeight, + 0f, drawableHeight + ) + matrix.mapPoints(points) + + val xs = listOf(points[0], points[2], points[4], points[6]) + val ys = listOf(points[1], points[3], points[5], points[7]) + + val minX = xs.minOrNull() ?: 0f + val maxX = xs.maxOrNull() ?: 0f + val minY = ys.minOrNull() ?: 0f + val maxY = ys.maxOrNull() ?: 0f + + val imageWidth = maxX - minX + val imageHeight = maxY - minY + + var deltaX = 0f + var deltaY = 0f + + // Horizontal bounds + if (imageWidth <= viewWidth) { + deltaX = (viewWidth - imageWidth) / 2 - minX + } else { + if (minX > 0) { + deltaX = -minX + } else if (maxX < viewWidth) { + deltaX = viewWidth - maxX + } + } + + // Vertical bounds + if (imageHeight <= viewHeight) { + deltaY = (viewHeight - imageHeight) / 2 - minY + } else { + if (minY > 0) { + deltaY = -minY + } else if (maxY < viewHeight) { + deltaY = viewHeight - maxY + } + } + + gestureMatrix.postTranslate(deltaX, deltaY) + } + + + private fun getImageDimensionsAfterBaseMatrix(): Pair { + val drawable = drawable ?: return Pair(0f, 0f) + + val points = floatArrayOf( + 0f, 0f, // top-left + drawable.intrinsicWidth.toFloat(), 0f, // top-right + drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat(), // bottom-right + 0f, drawable.intrinsicHeight.toFloat() // bottom-left + ) + + baseMatrix.mapPoints(points) + + val xs = listOf(points[0], points[2], points[4], points[6]) + val ys = listOf(points[1], points[3], points[5], points[7]) + + val width = (xs.maxOrNull() ?: 0f) - (xs.minOrNull() ?: 0f) + val height = (ys.maxOrNull() ?: 0f) - (ys.minOrNull() ?: 0f) + + return Pair(width, height) + } + + private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + isScaling = true + + // 🔥 LOCK pivot ONCE + scalePivotX = detector.focusX + scalePivotY = detector.focusY + + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + isScaling = false + + fixBounds() + imageMatrix = drawMatrix + invalidate() + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + + val parentView = findCropImageView() ?: return false + if (!parentView.isManualMode) return false + + val values = FloatArray(9) + drawMatrix.getValues(values) + + val currentScale = abs(values[Matrix.MSCALE_X]) + var scaleFactor = detector.scaleFactor + val newScale = currentScale * scaleFactor + + // Clamp scale + if (newScale > maxScale) { + scaleFactor = maxScale / currentScale + } else if (newScale < minScale) { + scaleFactor = minScale / currentScale + } + + if (scaleFactor.isNaN() || scaleFactor.isInfinite() || scaleFactor == 1f) { + return false + } + + // ✅ FIX: + // Scale around current finger focus in VIEW coordinates. + // Do NOT convert focus using drawMatrix.invert(). + drawMatrix.postScale( + scaleFactor, + scaleFactor, + detector.focusX, + detector.focusY + ) + + fixBounds() + + imageMatrix = drawMatrix + invalidate() + + return true + } + } + + private fun fixBounds() { + val drawable = drawable ?: return + + val rect = RectF( + 0f, + 0f, + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicHeight.toFloat() + ) + + drawMatrix.mapRect(rect) + + var dx = 0f + var dy = 0f + + if (rect.width() > width) { + if (rect.left > 0) dx = -rect.left + if (rect.right < width) dx = width - rect.right + } else { + dx = width / 2f - rect.centerX() + } + + if (rect.height() > height) { + if (rect.top > 0) dy = -rect.top + if (rect.bottom < height) dy = height - rect.bottom + } else { + dy = height / 2f - rect.centerY() + } + + drawMatrix.postTranslate(dx, dy) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + viewWidth = MeasureSpec.getSize(widthMeasureSpec) + viewHeight = MeasureSpec.getSize(heightMeasureSpec) + fitImageToView() + } + + private fun fitImageToView() { + val drawable = drawable ?: return + + val drawableWidth = drawable.intrinsicWidth.toFloat() + val drawableHeight = drawable.intrinsicHeight.toFloat() + + if (drawableWidth == 0f || drawableHeight == 0f) return + + // 🔥 Calculate base scale (fit center) + val scale = min( + viewWidth / drawableWidth, + viewHeight / drawableHeight + ) + + // 🔥 VERY IMPORTANT (fix your issue) + minScale = scale // ❗ prevents zoom-out below bounds + saveScale = scale + + // 🔥 Reset matrix completely + drawMatrix.reset() + + // Apply base scale + drawMatrix.postScale(scale, scale) + + // Center image + val redundantXSpace = (viewWidth - drawableWidth * scale) / 2f + val redundantYSpace = (viewHeight - drawableHeight * scale) / 2f + drawMatrix.postTranslate(redundantXSpace, redundantYSpace) + + // Apply flip + val flipX = if (flipHorizontally) -1f else 1f + val flipY = if (flipVertically) -1f else 1f + drawMatrix.postScale(flipX, flipY, viewWidth / 2f, viewHeight / 2f) + + // Apply EXIF rotation + drawMatrix.postRotate(exifAngle, viewWidth / 2f, viewHeight / 2f) + + // Apply manual rotation + drawMatrix.postRotate(rotatedDegrees.toFloat(), viewWidth / 2f, viewHeight / 2f) + + imageMatrix = drawMatrix + invalidate() + } + + /** + * This function generates the new bitmap for the cropped image... the execution comes to here only if the last touch is for the manual zoom. + */ + fun getTransformedBitmap(): Bitmap? { + val drawable = drawable ?: return null + val originalBitmap = (drawable as? BitmapDrawable)?.bitmap ?: return null + + // ✅ Use actual TouchImageView size + val resultBitmap = createBitmap(width, height) + + val canvas = Canvas(resultBitmap) + canvas.drawBitmap(originalBitmap, drawMatrix, null) + + return resultBitmap + } + + /** + * This function is essential for handling the manually done rotation in the image cropping. + */ + fun setImageManuallyRotatedDegrees(degrees: Int) { + rotatedDegrees = (rotatedDegrees + degrees) % 360 + if (rotatedDegrees < 0) rotatedDegrees += 360 + fitImageToView() + } + + companion object { + private const val NONE = 0 + private const val DRAG = 1 + } +} diff --git a/lassi/src/main/java/com/lassi/presentation/cropper/utils/GetUriForFile.kt b/lassi/src/main/java/com/lassi/presentation/cropper/utils/GetUriForFile.kt index d7d39e1..91c6873 100644 --- a/lassi/src/main/java/com/lassi/presentation/cropper/utils/GetUriForFile.kt +++ b/lassi/src/main/java/com/lassi/presentation/cropper/utils/GetUriForFile.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.util.Log import androidx.core.content.FileProvider +import com.lassi.BuildConfig import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -12,6 +13,7 @@ import java.io.InputStream import java.io.OutputStream import java.nio.file.Files import java.nio.file.Paths +import androidx.core.net.toUri internal fun Context.authority() = "$packageName.cropper.fileprovider" @@ -31,15 +33,9 @@ internal fun Context.authority() = "$packageName.cropper.fileprovider" internal fun getUriForFile(context: Context, file: File): Uri { val authority = context.authority() try { - Log.i("AIC", "Try get URI for scope storage - content://") return FileProvider.getUriForFile(context, authority, file) } catch (e: Exception) { try { - Log.e("AIC", "${e.message}") - Log.w( - "AIC", - "ANR Risk -- Copying the file the location cache to avoid 'external-files-path' bug for N+ devices", - ) // Note: Periodically clear this cache val cacheFolder = File(context.cacheDir, "CROP_LIB_CACHE") val cacheLocation = File(cacheFolder, file.name) @@ -49,14 +45,8 @@ internal fun getUriForFile(context: Context, file: File): Uri { input = FileInputStream(file) output = FileOutputStream(cacheLocation) // appending output stream input.copyTo(output) - Log.i( - "AIC", - "Completed Android N+ file copy. Attempting to return the cached file", - ) return FileProvider.getUriForFile(context, authority, cacheLocation) } catch (e: Exception) { - Log.e("AIC", "${e.message}") - Log.i("AIC", "Trying to provide URI manually") val path = "content://$authority/files/my_images/" if (SDK_INT >= 26) { @@ -65,31 +55,24 @@ internal fun getUriForFile(context: Context, file: File): Uri { val directory = File(path) if (!directory.exists()) directory.mkdirs() } - - return Uri.parse("$path${file.name}") + return "$path${file.name}".toUri() } finally { input?.close() output?.close() } } catch (e: Exception) { - Log.e("AIC", "${e.message}") if (SDK_INT < 29) { val cacheDir = context.externalCacheDir cacheDir?.let { try { - Log.i( - "AIC", - "Use External storage, do not work for OS 29 and above", - ) return Uri.fromFile(File(cacheDir.path, file.absolutePath)) } catch (e: Exception) { - Log.e("AIC", "${e.message}") + Log.e("AIC", "second catch block --------------> ${e.message}") } } } // If nothing else work we try - Log.i("AIC", "Try get URI using file://") return Uri.fromFile(file) } } diff --git a/lassi/src/main/java/com/lassi/presentation/mediadirectory/FolderFragment.kt b/lassi/src/main/java/com/lassi/presentation/mediadirectory/FolderFragment.kt index 666862f..3db4ec7 100644 --- a/lassi/src/main/java/com/lassi/presentation/mediadirectory/FolderFragment.kt +++ b/lassi/src/main/java/com/lassi/presentation/mediadirectory/FolderFragment.kt @@ -219,7 +219,6 @@ class FolderFragment : LassiBaseViewModelFragment= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { needsStorage = needsStorage && ActivityCompat.checkSelfPermission( requireContext(), Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED @@ -405,11 +401,9 @@ class FolderFragment : LassiBaseViewModelFragment } @@ -312,7 +311,6 @@ class LassiMediaPickerActivity : setResultOk(mediaPaths) } - private fun initCamera() { if (viewModel.selectedMediaLiveData.value?.size == config.maxCount) { ToastUtils.showToast(this, MultiLangConfig.getConfig().alreadySelectedMaxItems) @@ -342,34 +340,25 @@ class LassiMediaPickerActivity : finish() } - private fun croppingOptions( - uri: Uri? = null, includeCamera: Boolean? = false, includeGallery: Boolean? = false, - ) { - // Start picker to get image for cropping and then use the image in cropping activity. - cropImage.launch(includeCamera?.let { - includeGallery?.let { includeGallery -> - config.cropAspectRatio?.x?.let { x -> - config.cropAspectRatio?.y?.let { y -> - CropImageOptions( - imageSourceIncludeCamera = includeCamera, - imageSourceIncludeGallery = includeGallery, - cropShape = config.cropType, - showCropOverlay = true, - guidelines = CropImageView.Guidelines.ON, - multiTouchEnabled = false, - aspectRatioX = x, - aspectRatioY = y, - fixAspectRatio = config.enableActualCircleCrop, - outputCompressQuality = LassiConfig.getConfig().compressionRatio - ) - } - } - } - }?.let { - CropImageContractOptions( - uri = uri, - cropImageOptions = it, - ) - }) + private fun croppingOptions(uri: Uri) { + val config = LassiConfig.getConfig() + val aspectX: Int = config.cropAspectRatio?.x ?: return + val aspectY: Int = config.cropAspectRatio?.y ?: return + + val cropOptions = CropImageOptions( + imageSourceIncludeCamera = false, + imageSourceIncludeGallery = false, + cropShape = config.cropType, + showCropOverlay = true, + guidelines = CropImageView.Guidelines.ON, + multiTouchEnabled = false, + aspectRatioX = aspectX, + aspectRatioY = aspectY, + fixAspectRatio = config.enableActualCircleCrop, + outputCompressQuality = config.compressionRatio + ) + + val contractOptions = CropImageContractOptions(uri, cropOptions) + cropImage.launch(contractOptions) } } diff --git a/lassi/src/main/java/com/lassi/presentation/videopreview/VideoPreviewActivity.kt b/lassi/src/main/java/com/lassi/presentation/videopreview/VideoPreviewActivity.kt index d3db6be..1fe5a66 100644 --- a/lassi/src/main/java/com/lassi/presentation/videopreview/VideoPreviewActivity.kt +++ b/lassi/src/main/java/com/lassi/presentation/videopreview/VideoPreviewActivity.kt @@ -23,6 +23,7 @@ import com.lassi.databinding.ActivityVideoPreviewBinding import com.lassi.domain.media.LassiConfig import com.lassi.presentation.common.LassiBaseActivity import java.io.File +import androidx.core.graphics.drawable.toDrawable class VideoPreviewActivity : LassiBaseActivity() { private var videoPath: String? = null @@ -48,7 +49,9 @@ class VideoPreviewActivity : LassiBaseActivity() { controller.setAnchorView(this) controller.setMediaPlayer(this) setMediaController(controller) - setVideoURI(Uri.fromFile(File(videoPath))) + videoPath?.let { + setVideoURI(Uri.fromFile(File(it))) + } } binding.toolbar.applyTopInset() @@ -59,7 +62,7 @@ class VideoPreviewActivity : LassiBaseActivity() { binding.toolbar.title = "" with(LassiConfig.getConfig()) { binding.toolbar.background = - ColorDrawable(toolbarColor) + toolbarColor.toDrawable() binding.toolbar.setTitleTextColor(toolbarResourceColor) val upArrow = ContextCompat.getDrawable(this@VideoPreviewActivity, R.drawable.ic_back_white) diff --git a/lassi/src/main/res/layout/crop_image_view.xml b/lassi/src/main/res/layout/crop_image_view.xml index bbeceeb..e2faac0 100644 --- a/lassi/src/main/res/layout/crop_image_view.xml +++ b/lassi/src/main/res/layout/crop_image_view.xml @@ -1,21 +1,19 @@ - - + + android:scaleType="matrix" /> + + android:clickable="false" + android:focusable="false" /> + +