diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aeea88e..f5feee05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [x.x.x] - unreleased +## [4.2.1] - 04/04/2022 +### Added +- Added support for optionally displaying an intent chooser when selecting image source. [#325](https://github.com/CanHub/Android-Image-Cropper/issues/325) +### Changed +- CropException sealed class with cancellation and Image exceptions [#332](https://github.com/CanHub/Android-Image-Cropper/issues/332) +### Fixed +- Fix disable closing AlertDialog when touching outside the dialog [#334](https://github.com/CanHub/Android-Image-Cropper/issues/334) + ## [4.2.0] - 21/03/2022 ### Added - Added an option to skip manual editing and return entire image when required [#324](https://github.com/CanHub/Android-Image-Cropper/pull/324) diff --git a/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt b/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt index ab17f9ab..bf9f0ee6 100644 --- a/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt +++ b/cropper/src/main/java/com/canhub/cropper/BitmapUtils.kt @@ -132,9 +132,7 @@ internal object BitmapUtils { val bitmap = decodeImage(resolver, uri, options) BitmapSampled(bitmap, options.inSampleSize) } catch (e: Exception) { - throw RuntimeException( - "Failed to load sampled bitmap: $uri\r\n${e.message}", e - ) + throw CropException.FailedToLoadBitmap(uri, e.message) } } @@ -658,9 +656,7 @@ internal object BitmapUtils { result?.recycle() throw e } catch (e: Exception) { - throw RuntimeException( - "Failed to load sampled bitmap: $loadedImageUri\r\n${e.message}", e - ) + throw CropException.FailedToLoadBitmap(loadedImageUri, e.message) } return BitmapSampled(result, sampleSize) } @@ -704,7 +700,7 @@ internal object BitmapUtils { closeSafe(stream) } } while (options.inSampleSize <= 512) - throw RuntimeException("Failed to decode image: $uri") + throw CropException.FailedToDecodeImage(uri) } /** @@ -745,10 +741,7 @@ internal object BitmapUtils { closeSafe(stream) decoder?.recycle() } catch (e: Exception) { - throw RuntimeException( - "Failed to load sampled bitmap: $uri\r\n${e.message}", - e - ) + throw CropException.FailedToLoadBitmap(uri, e.message) } return BitmapSampled(null, 1) } diff --git a/cropper/src/main/java/com/canhub/cropper/CropException.kt b/cropper/src/main/java/com/canhub/cropper/CropException.kt new file mode 100644 index 00000000..4cfccf68 --- /dev/null +++ b/cropper/src/main/java/com/canhub/cropper/CropException.kt @@ -0,0 +1,17 @@ +package com.canhub.cropper + +import android.net.Uri + +sealed class CropException(message: String) : Exception(message) { + class Cancellation : CropException("$EXCEPTION_PREFIX cropping has been cancelled by the user") + class FailedToLoadBitmap(uri: Uri, message: String?) : + CropException("$EXCEPTION_PREFIX Failed to load sampled bitmap: $uri\r\n$message") + + class FailedToDecodeImage(uri: Uri) : + CropException("$EXCEPTION_PREFIX Failed to decode image: $uri") + + companion object { + + const val EXCEPTION_PREFIX = "crop:" + } +} diff --git a/cropper/src/main/java/com/canhub/cropper/CropImage.kt b/cropper/src/main/java/com/canhub/cropper/CropImage.kt index 6de30663..a88dc1cb 100644 --- a/cropper/src/main/java/com/canhub/cropper/CropImage.kt +++ b/cropper/src/main/java/com/canhub/cropper/CropImage.kt @@ -254,7 +254,7 @@ object CropImage { originalUri = null, bitmap = null, uriContent = null, - error = Exception("cropping has been cancelled by the user"), + error = CropException.Cancellation(), cropPoints = floatArrayOf(), cropRect = null, wholeImageRect = null, diff --git a/cropper/src/main/java/com/canhub/cropper/CropImageActivity.kt b/cropper/src/main/java/com/canhub/cropper/CropImageActivity.kt index 42f5ee4c..d90c6d35 100644 --- a/cropper/src/main/java/com/canhub/cropper/CropImageActivity.kt +++ b/cropper/src/main/java/com/canhub/cropper/CropImageActivity.kt @@ -62,6 +62,7 @@ open class CropImageActivity : if (savedInstanceState == null) { if (cropImageUri == null || cropImageUri == Uri.EMPTY) { when { + cropImageOptions.showIntentChooser -> showIntentChooser() cropImageOptions.imageSourceIncludeGallery && cropImageOptions.imageSourceIncludeCamera -> showImageSourceDialog(::openSource) @@ -86,6 +87,39 @@ open class CropImageActivity : } } + private fun showIntentChooser() { + val ciIntentChooser = CropImageIntentChooser( + activity = this, + callback = object : CropImageIntentChooser.ResultCallback { + override fun onSuccess(uri: Uri?) { + onPickImageResult(uri) + } + + override fun onCancelled() { + setResultCancel() + } + } + ) + cropImageOptions.let { options -> + options.intentChooserTitle + ?.takeIf { title -> title.isNotBlank() } + ?.let { icTitle -> + ciIntentChooser.setIntentChooserTitle(icTitle) + } + options.intentChooserPriorityList + ?.takeIf { appPriorityList -> appPriorityList.isNotEmpty() } + ?.let { appsList -> + ciIntentChooser.setupPriorityAppsList(appsList) + } + val cameraUri: Uri? = if (options.imageSourceIncludeCamera) getTmpFileUri() else null + ciIntentChooser.showChooserIntent( + includeCamera = options.imageSourceIncludeCamera, + includeGallery = options.imageSourceIncludeGallery, + cameraImgUri = cameraUri + ) + } + } + private fun openSource(source: Source) { when (source) { Source.CAMERA -> openCamera() @@ -115,6 +149,7 @@ open class CropImageActivity : */ open fun showImageSourceDialog(openSource: (Source) -> Unit) { AlertDialog.Builder(this) + .setCancelable(false) .setTitle(R.string.pick_image_chooser_title) .setItems( arrayOf( @@ -333,6 +368,7 @@ open class CropImageActivity : enum class Source { CAMERA, GALLERY } private companion object { + const val BUNDLE_KEY_TMP_URI = "bundle_key_tmp_uri" } } diff --git a/cropper/src/main/java/com/canhub/cropper/CropImageContractOptions.kt b/cropper/src/main/java/com/canhub/cropper/CropImageContractOptions.kt index 85015cb3..bdddaa7e 100644 --- a/cropper/src/main/java/com/canhub/cropper/CropImageContractOptions.kt +++ b/cropper/src/main/java/com/canhub/cropper/CropImageContractOptions.kt @@ -49,6 +49,7 @@ data class CropImageContractOptions @JvmOverloads constructor( cropImageOptions.cropShape = cropShape return this } + /** * To set the shape of the cropper corner (RECTANGLE / OVAL) * Default: RECTANGLE @@ -500,6 +501,41 @@ data class CropImageContractOptions @JvmOverloads constructor( cropImageOptions.showCropOverlay = !skipEditing return this } + + /** + * Shows an intent chooser instead of the alert dialog when choosing an image source. + * + * *Default: false* + * + * Note: To show the camera app as an option in Intent chooser you will need to add + * the camera permission ("android.permission.CAMERA") to your manifest file. + */ + fun setShowIntentChooser(showIntentChooser: Boolean) = cropImageOptions.apply { + this.showIntentChooser = showIntentChooser + } + + /** + * Sets a custom title for the intent chooser + */ + fun setIntentChooserTitle(intentChooserTitle: String) = cropImageOptions.apply { + this.intentChooserTitle = intentChooserTitle + } + + /** + * This takes the given app package list (list of app package names) + * and displays them first among the list of apps available + * + * @param priorityAppPackages accepts a list of strings of app package names + * Apps are displayed in the order you pass them if they are available on your device + * + * Note: If you pass an empty list here there will be no sorting of the apps list + * shown in the intent chooser. + * By default, the library sorts the list putting a few common + * apps like Google Photos and Google Photos Go at the start of the list. + */ + fun setIntentChooserPriorityList(priorityAppPackages: List) = cropImageOptions.apply { + this.intentChooserPriorityList = priorityAppPackages + } } fun options( diff --git a/cropper/src/main/java/com/canhub/cropper/CropImageIntentChooser.kt b/cropper/src/main/java/com/canhub/cropper/CropImageIntentChooser.kt new file mode 100644 index 00000000..0cebcdd1 --- /dev/null +++ b/cropper/src/main/java/com/canhub/cropper/CropImageIntentChooser.kt @@ -0,0 +1,224 @@ +package com.canhub.cropper + +import android.Manifest +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Parcelable +import android.provider.MediaStore +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts + +class CropImageIntentChooser( + private val activity: ComponentActivity, + private val callback: ResultCallback +) { + + interface ResultCallback { + + fun onSuccess(uri: Uri?) + + fun onCancelled() + } + + companion object { + + const val GOOGLE_PHOTOS = "com.google.android.apps.photos" + const val GOOGLE_PHOTOS_GO = "com.google.android.apps.photosgo" + const val SAMSUNG_GALLERY = "com.sec.android.gallery3d" + const val ONEPLUS_GALLERY = "com.oneplus.gallery" + const val MIUI_GALLERY = "com.miui.gallery" + } + + private var title: String = activity.getString(R.string.pick_image_chooser_title) + private var priorityIntentList = listOf( + GOOGLE_PHOTOS, + GOOGLE_PHOTOS_GO, + SAMSUNG_GALLERY, + ONEPLUS_GALLERY, + MIUI_GALLERY + ) + private var cameraImgUri: Uri? = null + private val intentChooser = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityRes -> + if (activityRes.resultCode == Activity.RESULT_OK) { + /* + Here we don't know whether a gallery app or the camera app is selected + via the intent chooser. If a gallery app is selected and an image is + chosen then we get the result from activityRes. + If a camera app is selected we take the uri we passed to the camera + app for storing the captured image + */ + (activityRes.data?.data ?: cameraImgUri).let { uri -> + callback.onSuccess(uri) + } + } else { + callback.onCancelled() + } + } + + /** + * Create a chooser intent to select the source to get image from.

+ * The source can be camera's (ACTION_IMAGE_CAPTURE) or gallery's (ACTION_GET_CONTENT).

+ * All possible sources are added to the intent chooser. + * + * @param includeCamera if to include camera intents + * @param includeGallery if to include Gallery app intents + * @param cameraImgUri required if includeCamera is set to true + */ + fun showChooserIntent( + includeCamera: Boolean, + includeGallery: Boolean, + cameraImgUri: Uri? = null + ) { + this.cameraImgUri = cameraImgUri + val allIntents: MutableList = ArrayList() + val packageManager = activity.packageManager + // collect all camera intents if Camera permission is available + if (!isExplicitCameraPermissionRequired(activity) && includeCamera) { + allIntents.addAll(getCameraIntents(activity, packageManager)) + } + if (includeGallery) { + var galleryIntents = getGalleryIntents(packageManager, Intent.ACTION_GET_CONTENT) + if (galleryIntents.isEmpty()) { + // if no intents found for get-content try pick intent action (Huawei P9). + galleryIntents = getGalleryIntents(packageManager, Intent.ACTION_PICK) + } + allIntents.addAll(galleryIntents) + } + val target = if (allIntents.isEmpty()) Intent() else { + Intent(Intent.ACTION_CHOOSER, MediaStore.Images.Media.EXTERNAL_CONTENT_URI).apply { + if (includeGallery) { + action = Intent.ACTION_PICK + type = "image/*" + } + } + } + // Create a chooser from the main intent + val chooserIntent = Intent.createChooser(target, title) + // Add all other intents + chooserIntent.putExtra( + Intent.EXTRA_INITIAL_INTENTS, allIntents.toTypedArray() + ) + intentChooser.launch(chooserIntent) + } + + /** + * Get all Camera intents for capturing image using device camera apps. + */ + private fun getCameraIntents(context: Context, packageManager: PackageManager): List { + val allIntents: MutableList = ArrayList() + // Determine Uri of camera image to save. + val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + val listCam = packageManager.queryIntentActivities(captureIntent, 0) + for (resolveInfo in listCam) { + val intent = Intent(captureIntent) + intent.component = ComponentName( + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name + ) + intent.setPackage(resolveInfo.activityInfo.packageName) + if (context is Activity) { + context.grantUriPermission( + resolveInfo.activityInfo.packageName, cameraImgUri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + intent.putExtra(MediaStore.EXTRA_OUTPUT, cameraImgUri) + allIntents.add(intent) + } + return allIntents + } + + /** + * Get all Gallery intents for getting image from one of the apps of the device that handle + * images. + * Note: It currently get only the main camera app intent. Still have to figure out + * how to get multiple camera apps to pick from (if available) + */ + private fun getGalleryIntents(packageManager: PackageManager, action: String): List { + val intents: MutableList = ArrayList() + val galleryIntent = if (action == Intent.ACTION_GET_CONTENT) Intent(action) + else Intent(action, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + galleryIntent.type = "image/*" + val listGallery = packageManager.queryIntentActivities(galleryIntent, 0) + for (res in listGallery) { + val intent = Intent(galleryIntent) + intent.component = ComponentName(res.activityInfo.packageName, res.activityInfo.name) + intent.setPackage(res.activityInfo.packageName) + intents.add(intent) + } + // sort intents + val priorityIntents = mutableListOf() + for (pkgName in priorityIntentList) { + intents.firstOrNull { it.`package` == pkgName }?.let { + intents.remove(it) + priorityIntents.add(it) + } + } + intents.addAll(0, priorityIntents) + return intents + } + + /** + * Check if explicetly requesting camera permission is required.

+ * It is required in Android Marshmellow and above if "CAMERA" permission is requested in the + * manifest.

+ * See [StackOverflow + * question](http://stackoverflow.com/questions/32789027/android-m-camera-intent-permission-bug). + */ + private fun isExplicitCameraPermissionRequired(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + hasCameraPermissionInManifest(context) && + context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED + } + + /** + * Check if the app requests a specific permission in the manifest. + * + * @param context the context of your activity to check for permissions + * @return true - the permission in requested in manifest, false - not. + */ + private fun hasCameraPermissionInManifest(context: Context): Boolean { + val packageName = context.packageName + try { + val packageInfo = + context.packageManager.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS) + val declaredPermissions = packageInfo.requestedPermissions + return declaredPermissions + ?.any { it?.equals("android.permission.CAMERA", true) == true } == true + } catch (e: PackageManager.NameNotFoundException) { + // Since the package name cannot be found we return false below + // because this means that the camera permission hasn't been declared + // by the user for this package so we can't show the camera app among + // among the list of apps + e.printStackTrace() + } + return false + } + + /** + * Set up a list of apps that you require to show first in the intent chooser + * Apps will show in the order it is passed + * + * @param appsList - pass a list of package names of apps of your choice + * + * This overrides the existing apps list + */ + fun setupPriorityAppsList(appsList: List): CropImageIntentChooser = apply { + priorityIntentList = appsList + } + + /** + * Set the title for the intent chooser + * + * @param title - the title for the intent chooser + */ + fun setIntentChooserTitle(title: String): CropImageIntentChooser = apply { + this.title = title + } +} diff --git a/cropper/src/main/java/com/canhub/cropper/CropImageOptions.kt b/cropper/src/main/java/com/canhub/cropper/CropImageOptions.kt index 82150410..136c9ccc 100644 --- a/cropper/src/main/java/com/canhub/cropper/CropImageOptions.kt +++ b/cropper/src/main/java/com/canhub/cropper/CropImageOptions.kt @@ -291,6 +291,26 @@ open class CropImageOptions : Parcelable { @JvmField var skipEditing: Boolean + /** + * Enabling this option replaces the current AlertDialog to choose the image source + * with an Intent chooser + */ + @JvmField + var showIntentChooser: Boolean + + /** + * optional, Sets a custom title for the intent chooser + */ + @JvmField + var intentChooserTitle: String? + + /** + * optional, reorders intent list displayed with the app package names + * passed here in order + */ + @JvmField + var intentChooserPriorityList: List? + /** Init options with defaults. */ constructor() { val dm = Resources.getSystem().displayMetrics @@ -350,6 +370,9 @@ open class CropImageOptions : Parcelable { cropMenuCropButtonTitle = null cropMenuCropButtonIcon = 0 skipEditing = false + showIntentChooser = false + intentChooserTitle = null + intentChooserPriorityList = listOf() } /** Create object from parcel. */ @@ -409,6 +432,9 @@ open class CropImageOptions : Parcelable { cropMenuCropButtonTitle = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel) cropMenuCropButtonIcon = parcel.readInt() skipEditing = parcel.readByte().toInt() != 0 + showIntentChooser = parcel.readByte().toInt() != 0 + intentChooserTitle = parcel.readString() + intentChooserPriorityList = parcel.createStringArrayList() } override fun writeToParcel(dest: Parcel, flags: Int) { @@ -467,6 +493,9 @@ open class CropImageOptions : Parcelable { TextUtils.writeToParcel(cropMenuCropButtonTitle, dest, flags) dest.writeInt(cropMenuCropButtonIcon) dest.writeByte((if (skipEditing) 1 else 0).toByte()) + dest.writeByte((if (showIntentChooser) 1 else 0).toByte()) + dest.writeString(intentChooserTitle) + dest.writeStringList(intentChooserPriorityList) } override fun describeContents(): Int { diff --git a/sample/src/main/java/com/canhub/cropper/sample/crop_image/app/SCropImageFragment.kt b/sample/src/main/java/com/canhub/cropper/sample/crop_image/app/SCropImageFragment.kt index 5d5eda7e..0b9072cc 100644 --- a/sample/src/main/java/com/canhub/cropper/sample/crop_image/app/SCropImageFragment.kt +++ b/sample/src/main/java/com/canhub/cropper/sample/crop_image/app/SCropImageFragment.kt @@ -191,6 +191,13 @@ internal class SCropImageFragment : Fragment(), SCropImageContract.View { // setNoOutputImage(false) // setFixAspectRatio(true) // setSkipEditing(true) +// setShowIntentChooser(true) +// setIntentChooserTitle("My Intent Chooser") +/* setIntentChooserPriorityList(listOf( + "com.miui.gallery", + "com.google.android.apps.photos" + )) +*/ } ) } diff --git a/versions.gradle b/versions.gradle index 6ebd6d3b..475b28d7 100644 --- a/versions.gradle +++ b/versions.gradle @@ -1,7 +1,7 @@ ext { // Project - libVersion = "4.2.0" + libVersion = "4.2.1" compileSdkVersion = 31 targetSdkVersion = 31 minSdkVersion = 16