@onekeyfe/react-native-image-crop-picker 3.0.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/ReactNativeImageCropPicker.podspec +40 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +144 -0
  6. package/android/gradle.properties +4 -0
  7. package/android/src/main/AndroidManifest.xml +24 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  9. package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerException.kt +45 -0
  10. package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerImageProcessor.kt +308 -0
  11. package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerSession.kt +297 -0
  12. package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPicker.kt +69 -0
  13. package/android/src/main/java/com/margelo/nitro/reactnativeimagecroppicker/ReactNativeImageCropPickerPackage.kt +24 -0
  14. package/ios/ImageCropPickerError.swift +57 -0
  15. package/ios/ImageCropPickerImageProcessor.swift +343 -0
  16. package/ios/ImageCropPickerSession.swift +407 -0
  17. package/ios/ReactNativeImageCropPicker.swift +65 -0
  18. package/ios/TOCropViewController/Categories/UIImage+CropRotate.h +39 -0
  19. package/ios/TOCropViewController/Categories/UIImage+CropRotate.m +94 -0
  20. package/ios/TOCropViewController/Constants/TOCropViewConstants.h +63 -0
  21. package/ios/TOCropViewController/LICENSE +21 -0
  22. package/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.h +38 -0
  23. package/ios/TOCropViewController/Models/TOActivityCroppedImageProvider.m +73 -0
  24. package/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.h +64 -0
  25. package/ios/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m +121 -0
  26. package/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.h +49 -0
  27. package/ios/TOCropViewController/Models/TOCropViewControllerTransitioning.m +125 -0
  28. package/ios/TOCropViewController/Models/TOCroppedImageAttributes.h +38 -0
  29. package/ios/TOCropViewController/Models/TOCroppedImageAttributes.m +45 -0
  30. package/ios/TOCropViewController/Resources/Base.lproj/TOCropViewControllerLocalizable.strings +8 -0
  31. package/ios/TOCropViewController/Resources/PrivacyInfo.xcprivacy +14 -0
  32. package/ios/TOCropViewController/Resources/ar.lproj/TOCropViewControllerLocalizable.strings +9 -0
  33. package/ios/TOCropViewController/Resources/ca.lproj/TOCropViewControllerLocalizable.strings +8 -0
  34. package/ios/TOCropViewController/Resources/cs.lproj/TOCropViewControllerLocalizable.strings +8 -0
  35. package/ios/TOCropViewController/Resources/da-DK.lproj/TOCropViewControllerLocalizable.strings +8 -0
  36. package/ios/TOCropViewController/Resources/de.lproj/TOCropViewControllerLocalizable.strings +8 -0
  37. package/ios/TOCropViewController/Resources/en.lproj/TOCropViewControllerLocalizable.strings +8 -0
  38. package/ios/TOCropViewController/Resources/es.lproj/TOCropViewControllerLocalizable.strings +8 -0
  39. package/ios/TOCropViewController/Resources/fa-IR.lproj/TOCropViewControllerLocalizable.strings +8 -0
  40. package/ios/TOCropViewController/Resources/fa.lproj/TOCropViewControllerLocalizable.strings +8 -0
  41. package/ios/TOCropViewController/Resources/fi.lproj/TOCropViewControllerLocalizable.strings +8 -0
  42. package/ios/TOCropViewController/Resources/fr.lproj/TOCropViewControllerLocalizable.strings +8 -0
  43. package/ios/TOCropViewController/Resources/hu.lproj/TOCropViewControllerLocalizable.strings +9 -0
  44. package/ios/TOCropViewController/Resources/id.lproj/TOCropViewControllerLocalizable.strings +8 -0
  45. package/ios/TOCropViewController/Resources/it.lproj/TOCropViewControllerLocalizable.strings +8 -0
  46. package/ios/TOCropViewController/Resources/ja.lproj/TOCropViewControllerLocalizable.strings +8 -0
  47. package/ios/TOCropViewController/Resources/ko.lproj/TOCropViewControllerLocalizable.strings +8 -0
  48. package/ios/TOCropViewController/Resources/ms.lproj/TOCropViewControllerLocalizable.strings +8 -0
  49. package/ios/TOCropViewController/Resources/nl.lproj/TOCropViewControllerLocalizable.strings +8 -0
  50. package/ios/TOCropViewController/Resources/pl.lproj/TOCropViewControllerLocalizable.strings +8 -0
  51. package/ios/TOCropViewController/Resources/pt-BR.lproj/TOCropViewControllerLocalizable.strings +8 -0
  52. package/ios/TOCropViewController/Resources/pt.lproj/TOCropViewControllerLocalizable.strings +8 -0
  53. package/ios/TOCropViewController/Resources/ro.lproj/TOCropViewControllerLocalizable.strings +8 -0
  54. package/ios/TOCropViewController/Resources/ru.lproj/TOCropViewControllerLocalizable.strings +8 -0
  55. package/ios/TOCropViewController/Resources/sk.lproj/TOCropViewControllerLocalizable.strings +8 -0
  56. package/ios/TOCropViewController/Resources/tr.lproj/TOCropViewControllerLocalizable.strings +9 -0
  57. package/ios/TOCropViewController/Resources/uk.lproj/TOCropViewControllerLocalizable.strings +8 -0
  58. package/ios/TOCropViewController/Resources/vi.lproj/TOCropViewControllerLocalizable.strings +9 -0
  59. package/ios/TOCropViewController/Resources/zh-Hans.lproj/TOCropViewControllerLocalizable.strings +9 -0
  60. package/ios/TOCropViewController/Resources/zh-Hant.lproj/TOCropViewControllerLocalizable.strings +9 -0
  61. package/ios/TOCropViewController/TOCropViewController.h +479 -0
  62. package/ios/TOCropViewController/TOCropViewController.m +1355 -0
  63. package/ios/TOCropViewController/Views/TOCropOverlayView.h +43 -0
  64. package/ios/TOCropViewController/Views/TOCropOverlayView.m +240 -0
  65. package/ios/TOCropViewController/Views/TOCropScrollView.h +39 -0
  66. package/ios/TOCropViewController/Views/TOCropScrollView.m +48 -0
  67. package/ios/TOCropViewController/Views/TOCropToolbar.h +104 -0
  68. package/ios/TOCropViewController/Views/TOCropToolbar.m +783 -0
  69. package/ios/TOCropViewController/Views/TOCropView.h +311 -0
  70. package/ios/TOCropViewController/Views/TOCropView.m +1787 -0
  71. package/lib/module/ReactNativeImageCropPicker.nitro.js +4 -0
  72. package/lib/module/ReactNativeImageCropPicker.nitro.js.map +1 -0
  73. package/lib/module/index.js +83 -0
  74. package/lib/module/index.js.map +1 -0
  75. package/lib/module/package.json +1 -0
  76. package/lib/typescript/package.json +1 -0
  77. package/lib/typescript/src/ReactNativeImageCropPicker.nitro.d.ts +54 -0
  78. package/lib/typescript/src/ReactNativeImageCropPicker.nitro.d.ts.map +1 -0
  79. package/lib/typescript/src/index.d.ts +31 -0
  80. package/lib/typescript/src/index.d.ts.map +1 -0
  81. package/nitro.json +23 -0
  82. package/nitrogen/generated/android/c++/JCropRect.hpp +69 -0
  83. package/nitrogen/generated/android/c++/JHybridReactNativeImageCropPickerSpec.cpp +125 -0
  84. package/nitrogen/generated/android/c++/JHybridReactNativeImageCropPickerSpec.hpp +66 -0
  85. package/nitrogen/generated/android/c++/JImageCropPickerOptions.hpp +154 -0
  86. package/nitrogen/generated/android/c++/JPickedImage.hpp +88 -0
  87. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/CropRect.kt +66 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/HybridReactNativeImageCropPickerSpec.kt +69 -0
  89. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/ImageCropPickerOptions.kt +171 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/PickedImage.kt +86 -0
  91. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeimagecroppicker/reactnativeimagecroppickerOnLoad.kt +35 -0
  92. package/nitrogen/generated/android/reactnativeimagecroppicker+autolinking.cmake +81 -0
  93. package/nitrogen/generated/android/reactnativeimagecroppicker+autolinking.gradle +27 -0
  94. package/nitrogen/generated/android/reactnativeimagecroppickerOnLoad.cpp +54 -0
  95. package/nitrogen/generated/android/reactnativeimagecroppickerOnLoad.hpp +34 -0
  96. package/nitrogen/generated/ios/ReactNativeImageCropPicker+autolinking.rb +62 -0
  97. package/nitrogen/generated/ios/ReactNativeImageCropPicker-Swift-Cxx-Bridge.cpp +57 -0
  98. package/nitrogen/generated/ios/ReactNativeImageCropPicker-Swift-Cxx-Bridge.hpp +221 -0
  99. package/nitrogen/generated/ios/ReactNativeImageCropPicker-Swift-Cxx-Umbrella.hpp +55 -0
  100. package/nitrogen/generated/ios/ReactNativeImageCropPickerAutolinking.mm +33 -0
  101. package/nitrogen/generated/ios/ReactNativeImageCropPickerAutolinking.swift +26 -0
  102. package/nitrogen/generated/ios/c++/HybridReactNativeImageCropPickerSpecSwift.cpp +11 -0
  103. package/nitrogen/generated/ios/c++/HybridReactNativeImageCropPickerSpecSwift.hpp +116 -0
  104. package/nitrogen/generated/ios/swift/CropRect.swift +44 -0
  105. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  106. package/nitrogen/generated/ios/swift/Func_void_PickedImage.swift +46 -0
  107. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  108. package/nitrogen/generated/ios/swift/HybridReactNativeImageCropPickerSpec.swift +58 -0
  109. package/nitrogen/generated/ios/swift/HybridReactNativeImageCropPickerSpec_cxx.swift +202 -0
  110. package/nitrogen/generated/ios/swift/ImageCropPickerOptions.swift +474 -0
  111. package/nitrogen/generated/ios/swift/PickedImage.swift +96 -0
  112. package/nitrogen/generated/shared/c++/CropRect.hpp +95 -0
  113. package/nitrogen/generated/shared/c++/HybridReactNativeImageCropPickerSpec.cpp +24 -0
  114. package/nitrogen/generated/shared/c++/HybridReactNativeImageCropPickerSpec.hpp +71 -0
  115. package/nitrogen/generated/shared/c++/ImageCropPickerOptions.hpp +180 -0
  116. package/nitrogen/generated/shared/c++/PickedImage.hpp +114 -0
  117. package/package.json +173 -0
  118. package/src/ReactNativeImageCropPicker.nitro.ts +72 -0
  119. package/src/index.tsx +134 -0
@@ -0,0 +1,308 @@
1
+ package com.margelo.nitro.reactnativeimagecroppicker
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.Matrix
7
+ import android.net.Uri
8
+ import android.util.Base64
9
+ import androidx.exifinterface.media.ExifInterface
10
+ import java.io.File
11
+ import java.io.FileOutputStream
12
+ import java.io.IOException
13
+ import java.io.InputStream
14
+ import java.util.UUID
15
+ import kotlin.math.floor
16
+ import kotlin.math.max
17
+ import kotlin.math.min
18
+ import kotlin.math.roundToInt
19
+
20
+ internal data class ImageCropPickerConfig(
21
+ val width: Int?,
22
+ val height: Int?,
23
+ val cropping: Boolean,
24
+ val includeBase64: Boolean,
25
+ val compressImageQuality: Double?,
26
+ val compressImageMaxWidth: Double?,
27
+ val compressImageMaxHeight: Double?,
28
+ val freeStyleCropEnabled: Boolean,
29
+ val cropperCircleOverlay: Boolean,
30
+ val cropperToolbarTitle: String?,
31
+ val cropperActiveWidgetColor: String?,
32
+ val cropperToolbarColor: String?,
33
+ val cropperToolbarWidgetColor: String?,
34
+ val cropperStatusBarLight: Boolean,
35
+ val cropperNavigationBarLight: Boolean,
36
+ val showCropGuidelines: Boolean,
37
+ val showCropFrame: Boolean,
38
+ val enableRotationGesture: Boolean,
39
+ val hideBottomControls: Boolean,
40
+ val disableCropperColorSetters: Boolean,
41
+ ) {
42
+ companion object {
43
+ fun from(options: ImageCropPickerOptions, forceCropping: Boolean) = ImageCropPickerConfig(
44
+ width = options.width?.takeIf { it.isFinite() }?.roundToInt()?.takeIf { it > 0 },
45
+ height = options.height?.takeIf { it.isFinite() }?.roundToInt()?.takeIf { it > 0 },
46
+ cropping = forceCropping || options.cropping == true,
47
+ includeBase64 = options.includeBase64 == true,
48
+ compressImageQuality = options.compressImageQuality,
49
+ compressImageMaxWidth = options.compressImageMaxWidth,
50
+ compressImageMaxHeight = options.compressImageMaxHeight,
51
+ freeStyleCropEnabled = options.freeStyleCropEnabled == true,
52
+ cropperCircleOverlay = options.cropperCircleOverlay == true,
53
+ cropperToolbarTitle = options.cropperToolbarTitle,
54
+ cropperActiveWidgetColor = options.cropperActiveWidgetColor,
55
+ cropperToolbarColor = options.cropperToolbarColor,
56
+ cropperToolbarWidgetColor = options.cropperToolbarWidgetColor,
57
+ cropperStatusBarLight = options.cropperStatusBarLight ?: true,
58
+ cropperNavigationBarLight = options.cropperNavigationBarLight ?: false,
59
+ showCropGuidelines = options.showCropGuidelines ?: true,
60
+ showCropFrame = options.showCropFrame ?: true,
61
+ enableRotationGesture = options.enableRotationGesture == true,
62
+ hideBottomControls = options.hideBottomControls == true,
63
+ disableCropperColorSetters = options.disableCropperColorSetters == true,
64
+ )
65
+ }
66
+ }
67
+
68
+ internal object ImageCropPickerImageProcessor {
69
+ // Bounds memory for huge photos. Crop targets are far smaller than this.
70
+ private const val MAX_DECODED_PIXEL_SIZE = 4096
71
+
72
+ // Matches react-native-image-crop-picker on Android.
73
+ private const val DEFAULT_COMPRESS_QUALITY = 1.0
74
+ private const val TEMPORARY_DIRECTORY_NAME = "react-native-image-crop-picker"
75
+
76
+ fun temporaryDirectory(context: Context): File {
77
+ val directory = File(context.cacheDir, TEMPORARY_DIRECTORY_NAME)
78
+ if (!directory.isDirectory && !directory.mkdirs()) {
79
+ throw ImageCropPickerException.cannotSaveImage()
80
+ }
81
+ return directory
82
+ }
83
+
84
+ fun createTemporaryFile(context: Context, extension: String): File =
85
+ File(temporaryDirectory(context), "${UUID.randomUUID()}.$extension")
86
+
87
+ fun cleanTemporaryDirectory(context: Context) {
88
+ val directory = File(context.cacheDir, TEMPORARY_DIRECTORY_NAME)
89
+ if (directory.exists() && !directory.deleteRecursively()) {
90
+ throw ImageCropPickerException.cleanupFailed()
91
+ }
92
+ }
93
+
94
+ fun removeFile(path: String) {
95
+ val file = fileFromPath(path) ?: throw ImageCropPickerException.cleanupFailed()
96
+ if (!file.delete()) {
97
+ throw ImageCropPickerException.cleanupFailed()
98
+ }
99
+ }
100
+
101
+ private fun fileFromPath(path: String): File? = when {
102
+ path.startsWith("file://") -> Uri.parse(path).path?.let { File(it) }
103
+ path.startsWith("/") -> File(path)
104
+ else -> null
105
+ }
106
+
107
+ // Resolves an openCropper source into a Uri that uCrop can read. Returns the
108
+ // temporary file it had to create, if any, so the caller can delete it.
109
+ fun resolveCropSource(context: Context, path: String): Pair<Uri, File?> {
110
+ when {
111
+ path.startsWith("data:") -> {
112
+ val commaIndex = path.indexOf(',')
113
+ if (commaIndex < 0) {
114
+ throw ImageCropPickerException.cropperImageNotFound()
115
+ }
116
+ val bytes = try {
117
+ Base64.decode(path.substring(commaIndex + 1), Base64.DEFAULT)
118
+ } catch (error: IllegalArgumentException) {
119
+ throw ImageCropPickerException.cropperImageNotFound()
120
+ }
121
+ val file = createTemporaryFile(context, "img")
122
+ try {
123
+ file.writeBytes(bytes)
124
+ } catch (error: IOException) {
125
+ throw ImageCropPickerException.cannotSaveImage(error)
126
+ }
127
+ return Uri.fromFile(file) to file
128
+ }
129
+ // uCrop downloads remote sources and reads content URIs itself.
130
+ path.startsWith("http://") || path.startsWith("https://") || path.startsWith("content://") ->
131
+ return Uri.parse(path) to null
132
+ else -> {
133
+ val file = fileFromPath(path)
134
+ if (file == null || !file.isFile) {
135
+ throw ImageCropPickerException.cropperImageNotFound()
136
+ }
137
+ return Uri.fromFile(file) to null
138
+ }
139
+ }
140
+ }
141
+
142
+ // Decodes an image with its EXIF orientation applied, downsampled so its
143
+ // longest side is at most MAX_DECODED_PIXEL_SIZE.
144
+ fun decodeBitmap(context: Context, uri: Uri): Bitmap {
145
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
146
+ openStream(context, uri).use { BitmapFactory.decodeStream(it, null, bounds) }
147
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
148
+ throw ImageCropPickerException.noImageData("Invalid image selected")
149
+ }
150
+
151
+ var sampleSize = 1
152
+ while (max(bounds.outWidth, bounds.outHeight) / (sampleSize * 2) >= MAX_DECODED_PIXEL_SIZE) {
153
+ sampleSize *= 2
154
+ }
155
+ val options = BitmapFactory.Options().apply { inSampleSize = sampleSize }
156
+ val sampled = try {
157
+ openStream(context, uri).use { BitmapFactory.decodeStream(it, null, options) }
158
+ } catch (error: OutOfMemoryError) {
159
+ throw ImageCropPickerException.lowMemory(error)
160
+ } ?: throw ImageCropPickerException.noImageData("Invalid image selected")
161
+
162
+ val orientation = try {
163
+ openStream(context, uri).use {
164
+ ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
165
+ }
166
+ } catch (error: IOException) {
167
+ ExifInterface.ORIENTATION_NORMAL
168
+ }
169
+ val oriented = applyOrientation(sampled, orientation)
170
+ val scale = min(
171
+ MAX_DECODED_PIXEL_SIZE.toDouble() / oriented.width,
172
+ MAX_DECODED_PIXEL_SIZE.toDouble() / oriented.height,
173
+ )
174
+ return if (scale < 1.0) {
175
+ scaleBitmap(oriented, floorSize(oriented.width * scale), floorSize(oriented.height * scale))
176
+ } else {
177
+ oriented
178
+ }
179
+ }
180
+
181
+ // Scales a cropped image to the requested size; smaller crops are scaled up.
182
+ // With a locked aspect ratio the crop can still end up a few pixels off that
183
+ // ratio, so the result is scaled to exactly the requested size, as
184
+ // react-native-image-crop-picker did. Free-style crops keep their own aspect
185
+ // ratio and fit inside it.
186
+ fun resizeCroppedBitmap(bitmap: Bitmap, config: ImageCropPickerConfig): Bitmap {
187
+ val width = config.width ?: return bitmap
188
+ val height = config.height ?: return bitmap
189
+ if (!config.freeStyleCropEnabled) {
190
+ return scaleBitmap(bitmap, width, height)
191
+ }
192
+ val widthRatio = width.toDouble() / bitmap.width
193
+ val heightRatio = height.toDouble() / bitmap.height
194
+ return if (widthRatio < heightRatio) {
195
+ scaleBitmap(bitmap, width, roundSize(bitmap.height * widthRatio))
196
+ } else {
197
+ scaleBitmap(bitmap, roundSize(bitmap.width * heightRatio), height)
198
+ }
199
+ }
200
+
201
+ fun makeResult(
202
+ context: Context,
203
+ bitmap: Bitmap,
204
+ config: ImageCropPickerConfig,
205
+ cropRect: CropRect?,
206
+ filename: String?,
207
+ ): PickedImage {
208
+ var output = bitmap
209
+ val maxWidth = config.compressImageMaxWidth
210
+ val maxHeight = config.compressImageMaxHeight
211
+ val shouldResizeWidth = maxWidth != null && maxWidth < output.width
212
+ val shouldResizeHeight = maxHeight != null && maxHeight < output.height
213
+ if (shouldResizeWidth || shouldResizeHeight) {
214
+ val scale = min(
215
+ (maxWidth ?: output.width.toDouble()) / output.width,
216
+ (maxHeight ?: output.height.toDouble()) / output.height,
217
+ )
218
+ output = scaleBitmap(output, floorSize(output.width * scale), floorSize(output.height * scale))
219
+ }
220
+
221
+ val quality = ((config.compressImageQuality ?: DEFAULT_COMPRESS_QUALITY).coerceIn(0.0, 1.0) * 100).roundToInt()
222
+ val file = createTemporaryFile(context, "jpg")
223
+ try {
224
+ FileOutputStream(file).use { stream ->
225
+ if (!output.compress(Bitmap.CompressFormat.JPEG, quality, stream)) {
226
+ throw ImageCropPickerException.cannotSaveImage()
227
+ }
228
+ }
229
+ } catch (error: IOException) {
230
+ file.delete()
231
+ throw ImageCropPickerException.cannotSaveImage(error)
232
+ }
233
+
234
+ val data = if (config.includeBase64) {
235
+ Base64.encodeToString(file.readBytes(), Base64.NO_WRAP)
236
+ } else {
237
+ null
238
+ }
239
+ return PickedImage(
240
+ path = Uri.fromFile(file).toString(),
241
+ size = file.length().toDouble(),
242
+ width = output.width.toDouble(),
243
+ height = output.height.toDouble(),
244
+ mime = "image/jpeg",
245
+ data = data,
246
+ cropRect = cropRect,
247
+ filename = filename,
248
+ )
249
+ }
250
+
251
+ private fun openStream(context: Context, uri: Uri): InputStream =
252
+ try {
253
+ context.contentResolver.openInputStream(uri)
254
+ } catch (error: Exception) {
255
+ null
256
+ } ?: throw ImageCropPickerException.noImageData()
257
+
258
+ private fun floorSize(value: Double): Int = max(1, floor(value).toInt())
259
+
260
+ private fun roundSize(value: Double): Int = max(1, value.roundToInt())
261
+
262
+ private fun scaleBitmap(bitmap: Bitmap, width: Int, height: Int): Bitmap {
263
+ if (width == bitmap.width && height == bitmap.height) {
264
+ return bitmap
265
+ }
266
+ val scaled = try {
267
+ Bitmap.createScaledBitmap(bitmap, width, height, true)
268
+ } catch (error: OutOfMemoryError) {
269
+ throw ImageCropPickerException.lowMemory(error)
270
+ }
271
+ if (scaled !== bitmap) {
272
+ bitmap.recycle()
273
+ }
274
+ return scaled
275
+ }
276
+
277
+ private fun applyOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
278
+ val matrix = Matrix()
279
+ when (orientation) {
280
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
281
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
282
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
283
+ matrix.setRotate(180f)
284
+ matrix.postScale(-1f, 1f)
285
+ }
286
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
287
+ matrix.setRotate(90f)
288
+ matrix.postScale(-1f, 1f)
289
+ }
290
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
291
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
292
+ matrix.setRotate(-90f)
293
+ matrix.postScale(-1f, 1f)
294
+ }
295
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
296
+ else -> return bitmap
297
+ }
298
+ val oriented = try {
299
+ Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
300
+ } catch (error: OutOfMemoryError) {
301
+ throw ImageCropPickerException.lowMemory(error)
302
+ }
303
+ if (oriented !== bitmap) {
304
+ bitmap.recycle()
305
+ }
306
+ return oriented
307
+ }
308
+ }
@@ -0,0 +1,297 @@
1
+ package com.margelo.nitro.reactnativeimagecroppicker
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Color
6
+ import android.net.Uri
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.provider.OpenableColumns
10
+ import androidx.activity.ComponentActivity
11
+ import androidx.activity.result.ActivityResult
12
+ import androidx.activity.result.ActivityResultLauncher
13
+ import androidx.activity.result.PickVisualMediaRequest
14
+ import androidx.activity.result.contract.ActivityResultContract
15
+ import androidx.activity.result.contract.ActivityResultContracts
16
+ import com.margelo.nitro.core.Promise
17
+ import com.margelo.nitro.nativelogger.OneKeyLog
18
+ import com.yalantis.ucrop.UCrop
19
+ import com.yalantis.ucrop.UCropActivity
20
+ import java.io.File
21
+ import java.util.UUID
22
+ import java.util.concurrent.ExecutorService
23
+ import java.util.concurrent.Executors
24
+
25
+ // One picker or cropper flow. It owns the JS promise until the launched
26
+ // activities report back, and settles it exactly once.
27
+ internal class ImageCropPickerSession(
28
+ private val activity: ComponentActivity,
29
+ private val mode: Mode,
30
+ private val config: ImageCropPickerConfig,
31
+ private val promise: Promise<PickedImage>,
32
+ private val onFinish: (ImageCropPickerSession) -> Unit,
33
+ ) {
34
+ sealed class Mode {
35
+ object Picker : Mode()
36
+
37
+ class Cropper(val path: String) : Mode()
38
+ }
39
+
40
+ // Main thread state.
41
+ private val keyPrefix = "onekey-image-crop-picker-${UUID.randomUUID()}"
42
+ private val launchers = mutableListOf<ActivityResultLauncher<*>>()
43
+ private val temporaryFiles = mutableListOf<File>()
44
+ private var pickedFilename: String? = null
45
+ private var isAwaitingResult = false
46
+ private var isProcessing = false
47
+ private var isFinished = false
48
+
49
+ // Whether a launched activity or background work is still pending.
50
+ val isActive: Boolean
51
+ get() = !isFinished && (isProcessing || (isAwaitingResult && !activity.isDestroyed))
52
+
53
+ fun start() {
54
+ when (mode) {
55
+ is Mode.Picker -> launchPicker()
56
+ is Mode.Cropper -> resolveCropperSource(mode.path)
57
+ }
58
+ }
59
+
60
+ // Settles a session whose activity went away without calling back.
61
+ fun abandon() {
62
+ finish(Result.failure(ImageCropPickerException.cancelled()))
63
+ }
64
+
65
+ // The system Photo Picker needs no storage or media permission. On devices
66
+ // without it, PickVisualMedia falls back to ACTION_OPEN_DOCUMENT.
67
+ private fun launchPicker() {
68
+ val launcher = register(ActivityResultContracts.PickVisualMedia()) { uri ->
69
+ when {
70
+ uri == null -> finish(Result.failure(ImageCropPickerException.cancelled()))
71
+ config.cropping -> {
72
+ pickedFilename = queryDisplayName(uri)
73
+ launchCropper(uri)
74
+ }
75
+ else -> processPickedImage(uri)
76
+ }
77
+ }
78
+ launch {
79
+ launcher.launch(
80
+ PickVisualMediaRequest.Builder()
81
+ .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly)
82
+ .build(),
83
+ )
84
+ }
85
+ }
86
+
87
+ private fun processPickedImage(uri: Uri) {
88
+ val filename = queryDisplayName(uri)
89
+ runInBackground(
90
+ work = {
91
+ ImageCropPickerImageProcessor.makeResult(
92
+ activity,
93
+ ImageCropPickerImageProcessor.decodeBitmap(activity, uri),
94
+ config,
95
+ null,
96
+ filename,
97
+ )
98
+ },
99
+ onComplete = ::finish,
100
+ )
101
+ }
102
+
103
+ private fun resolveCropperSource(path: String) {
104
+ runInBackground(
105
+ work = { ImageCropPickerImageProcessor.resolveCropSource(activity, path) },
106
+ onComplete = { result ->
107
+ result.fold(
108
+ onSuccess = { (source, temporaryFile) ->
109
+ temporaryFile?.let { temporaryFiles.add(it) }
110
+ launchCropper(source)
111
+ },
112
+ onFailure = { finish(Result.failure(it)) },
113
+ )
114
+ },
115
+ )
116
+ }
117
+
118
+ private fun launchCropper(source: Uri) {
119
+ val destination = try {
120
+ ImageCropPickerImageProcessor.createTemporaryFile(activity, "jpg")
121
+ } catch (error: ImageCropPickerException) {
122
+ finish(Result.failure(error))
123
+ return
124
+ }
125
+ temporaryFiles.add(destination)
126
+
127
+ val cropper = UCrop.of(source, Uri.fromFile(destination)).withOptions(buildCropOptions())
128
+ if (config.width != null && config.height != null) {
129
+ cropper.withAspectRatio(config.width.toFloat(), config.height.toFloat())
130
+ }
131
+ val launcher = register(ActivityResultContracts.StartActivityForResult()) { result ->
132
+ handleCropResult(result)
133
+ }
134
+ launch { launcher.launch(cropper.getIntent(activity)) }
135
+ }
136
+
137
+ private fun handleCropResult(result: ActivityResult) {
138
+ val data = result.data
139
+ when (result.resultCode) {
140
+ Activity.RESULT_OK -> {
141
+ val output = data?.let { UCrop.getOutput(it) }
142
+ if (data == null || output == null) {
143
+ finish(Result.failure(ImageCropPickerException.noImageData()))
144
+ return
145
+ }
146
+ val cropRect = CropRect(
147
+ x = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_X, -1).toDouble(),
148
+ y = data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_Y, -1).toDouble(),
149
+ width = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_WIDTH, -1).toDouble(),
150
+ height = data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_HEIGHT, -1).toDouble(),
151
+ )
152
+ val filename = pickedFilename
153
+ runInBackground(
154
+ work = {
155
+ val cropped = ImageCropPickerImageProcessor.decodeBitmap(activity, output)
156
+ ImageCropPickerImageProcessor.makeResult(
157
+ activity,
158
+ ImageCropPickerImageProcessor.resizeCroppedBitmap(cropped, config),
159
+ config,
160
+ cropRect,
161
+ filename,
162
+ )
163
+ },
164
+ onComplete = ::finish,
165
+ )
166
+ }
167
+ UCrop.RESULT_ERROR -> {
168
+ val message = data?.let { UCrop.getError(it)?.message } ?: "Cannot crop image"
169
+ finish(Result.failure(ImageCropPickerException.noImageData(message)))
170
+ }
171
+ else -> finish(Result.failure(ImageCropPickerException.cancelled()))
172
+ }
173
+ }
174
+
175
+ private fun buildCropOptions(): UCrop.Options = UCrop.Options().apply {
176
+ setCompressionFormat(Bitmap.CompressFormat.JPEG)
177
+ setCompressionQuality(100)
178
+ setCircleDimmedLayer(config.cropperCircleOverlay)
179
+ setFreeStyleCropEnabled(config.freeStyleCropEnabled)
180
+ setShowCropGrid(config.showCropGuidelines)
181
+ setShowCropFrame(config.showCropFrame)
182
+ setHideBottomControls(config.hideBottomControls)
183
+ config.cropperToolbarTitle?.let { setToolbarTitle(it) }
184
+ if (config.enableRotationGesture) {
185
+ setAllowedGestures(UCropActivity.ALL, UCropActivity.ALL, UCropActivity.ALL)
186
+ }
187
+ if (!config.disableCropperColorSetters) {
188
+ parseColor(config.cropperActiveWidgetColor)?.let { setActiveControlsWidgetColor(it) }
189
+ parseColor(config.cropperToolbarColor)?.let { setToolbarColor(it) }
190
+ parseColor(config.cropperToolbarWidgetColor)?.let { setToolbarWidgetColor(it) }
191
+ setStatusBarLight(config.cropperStatusBarLight)
192
+ setNavigationBarLight(config.cropperNavigationBarLight)
193
+ }
194
+ }
195
+
196
+ private fun <I, O> register(
197
+ contract: ActivityResultContract<I, O>,
198
+ callback: (O) -> Unit,
199
+ ): ActivityResultLauncher<I> {
200
+ val key = "$keyPrefix-${launchers.size}"
201
+ val launcher = activity.activityResultRegistry.register(key, contract) { output ->
202
+ isAwaitingResult = false
203
+ if (!isFinished) {
204
+ callback(output)
205
+ }
206
+ }
207
+ launchers.add(launcher)
208
+ return launcher
209
+ }
210
+
211
+ private fun launch(block: () -> Unit) {
212
+ isAwaitingResult = true
213
+ try {
214
+ block()
215
+ } catch (error: Exception) {
216
+ isAwaitingResult = false
217
+ finish(Result.failure(ImageCropPickerException.failedToShowPicker(error)))
218
+ }
219
+ }
220
+
221
+ private fun <T> runInBackground(work: () -> T, onComplete: (Result<T>) -> Unit) {
222
+ isProcessing = true
223
+ executor.execute {
224
+ val result = try {
225
+ Result.success(work())
226
+ } catch (error: ImageCropPickerException) {
227
+ Result.failure(error)
228
+ } catch (error: OutOfMemoryError) {
229
+ Result.failure(ImageCropPickerException.lowMemory(error))
230
+ } catch (error: Exception) {
231
+ Result.failure(ImageCropPickerException.noImageData(error.message ?: "Cannot process image"))
232
+ }
233
+ mainHandler.post {
234
+ isProcessing = false
235
+ if (!isFinished) {
236
+ onComplete(result)
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ private fun finish(result: Result<PickedImage>) {
243
+ if (isFinished) {
244
+ return
245
+ }
246
+ isFinished = true
247
+ isAwaitingResult = false
248
+
249
+ // Unregister outside of the registry's dispatch callback.
250
+ val finishedLaunchers = launchers.toList()
251
+ launchers.clear()
252
+ mainHandler.post { finishedLaunchers.forEach { it.unregister() } }
253
+
254
+ val filesToDelete = temporaryFiles.toList()
255
+ temporaryFiles.clear()
256
+ if (filesToDelete.isNotEmpty()) {
257
+ executor.execute { filesToDelete.forEach { it.delete() } }
258
+ }
259
+
260
+ result.fold(
261
+ onSuccess = { promise.resolve(it) },
262
+ onFailure = { error ->
263
+ if ((error as? ImageCropPickerException)?.code != ImageCropPickerException.E_PICKER_CANCELLED) {
264
+ OneKeyLog.warn(TAG, error.message ?: error.toString())
265
+ }
266
+ promise.reject(error)
267
+ },
268
+ )
269
+ onFinish(this)
270
+ }
271
+
272
+ private fun queryDisplayName(uri: Uri): String? {
273
+ val displayName = try {
274
+ activity.contentResolver
275
+ .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
276
+ ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
277
+ } catch (error: Exception) {
278
+ null
279
+ }
280
+ return displayName ?: uri.lastPathSegment
281
+ }
282
+
283
+ private fun parseColor(value: String?): Int? =
284
+ value?.let {
285
+ try {
286
+ Color.parseColor(it)
287
+ } catch (error: IllegalArgumentException) {
288
+ null
289
+ }
290
+ }
291
+
292
+ companion object {
293
+ private const val TAG = "ImageCropPicker"
294
+ private val mainHandler = Handler(Looper.getMainLooper())
295
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor()
296
+ }
297
+ }
@@ -0,0 +1,69 @@
1
+ package com.margelo.nitro.reactnativeimagecroppicker
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import androidx.activity.ComponentActivity
6
+ import com.facebook.proguard.annotations.DoNotStrip
7
+ import com.margelo.nitro.NitroModules
8
+ import com.margelo.nitro.core.Promise
9
+
10
+ @DoNotStrip
11
+ class ReactNativeImageCropPicker : HybridReactNativeImageCropPickerSpec() {
12
+ private val mainHandler = Handler(Looper.getMainLooper())
13
+
14
+ // Main thread only.
15
+ private var activeSession: ImageCropPickerSession? = null
16
+
17
+ override fun openPicker(options: ImageCropPickerOptions): Promise<PickedImage> =
18
+ startSession(
19
+ ImageCropPickerSession.Mode.Picker,
20
+ ImageCropPickerConfig.from(options, forceCropping = false),
21
+ )
22
+
23
+ override fun openCropper(path: String, options: ImageCropPickerOptions): Promise<PickedImage> =
24
+ startSession(
25
+ ImageCropPickerSession.Mode.Cropper(path),
26
+ ImageCropPickerConfig.from(options, forceCropping = true),
27
+ )
28
+
29
+ override fun clean(): Promise<Unit> =
30
+ Promise.parallel {
31
+ val context = NitroModules.applicationContext ?: throw ImageCropPickerException.cleanupFailed()
32
+ ImageCropPickerImageProcessor.cleanTemporaryDirectory(context)
33
+ }
34
+
35
+ override fun cleanSingle(path: String): Promise<Unit> =
36
+ Promise.parallel { ImageCropPickerImageProcessor.removeFile(path) }
37
+
38
+ private fun startSession(
39
+ mode: ImageCropPickerSession.Mode,
40
+ config: ImageCropPickerConfig,
41
+ ): Promise<PickedImage> {
42
+ val promise = Promise<PickedImage>()
43
+ mainHandler.post {
44
+ activeSession?.let { session ->
45
+ if (session.isActive) {
46
+ promise.reject(ImageCropPickerException.inProgress())
47
+ return@post
48
+ }
49
+ // Its activity went away without calling back; don't block new requests on it.
50
+ session.abandon()
51
+ }
52
+
53
+ val activity = NitroModules.applicationContext?.currentActivity as? ComponentActivity
54
+ if (activity == null || activity.isFinishing || activity.isDestroyed) {
55
+ promise.reject(ImageCropPickerException.noActivity())
56
+ return@post
57
+ }
58
+
59
+ val session = ImageCropPickerSession(activity, mode, config, promise) { finishedSession ->
60
+ if (activeSession === finishedSession) {
61
+ activeSession = null
62
+ }
63
+ }
64
+ activeSession = session
65
+ session.start()
66
+ }
67
+ return promise
68
+ }
69
+ }
@@ -0,0 +1,24 @@
1
+ package com.margelo.nitro.reactnativeimagecroppicker
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfoProvider
7
+
8
+ class ReactNativeImageCropPickerPackage : BaseReactPackage() {
9
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
10
+ return null
11
+ }
12
+
13
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
14
+ return ReactModuleInfoProvider { HashMap() }
15
+ }
16
+
17
+ companion object {
18
+ init {
19
+ System.loadLibrary("reactnativeimagecroppicker")
20
+ }
21
+ }
22
+ }
23
+
24
+