react-native-image-compression-kit 0.1.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +562 -0
  3. package/android/build.gradle +63 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/imagecompressionkit/ImageCompressionKitModule.kt +1127 -0
  6. package/android/src/main/java/com/imagecompressionkit/ImageCompressionKitPackage.kt +33 -0
  7. package/android/src/main/java/com/imagecompressionkit/ImageCompressionOutput.kt +396 -0
  8. package/android/src/main/java/com/imagecompressionkit/JpegExifMetadata.kt +233 -0
  9. package/ios/RCTImageCompressionKit.h +10 -0
  10. package/ios/RCTImageCompressionKit.mm +72 -0
  11. package/lib/NativeImageCompressionKit.d.ts +55 -0
  12. package/lib/NativeImageCompressionKit.d.ts.map +1 -0
  13. package/lib/NativeImageCompressionKit.js +5 -0
  14. package/lib/NativeImageCompressionKit.js.map +1 -0
  15. package/lib/api.d.ts +4 -0
  16. package/lib/api.d.ts.map +1 -0
  17. package/lib/api.js +25 -0
  18. package/lib/api.js.map +1 -0
  19. package/lib/errors.d.ts +9 -0
  20. package/lib/errors.d.ts.map +1 -0
  21. package/lib/errors.js +53 -0
  22. package/lib/errors.js.map +1 -0
  23. package/lib/index.d.ts +5 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +14 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/nativeModule.d.ts +7 -0
  28. package/lib/nativeModule.d.ts.map +1 -0
  29. package/lib/nativeModule.js +73 -0
  30. package/lib/nativeModule.js.map +1 -0
  31. package/lib/types.d.ts +59 -0
  32. package/lib/types.d.ts.map +1 -0
  33. package/lib/types.js +23 -0
  34. package/lib/types.js.map +1 -0
  35. package/lib/validation.d.ts +3 -0
  36. package/lib/validation.d.ts.map +1 -0
  37. package/lib/validation.js +108 -0
  38. package/lib/validation.js.map +1 -0
  39. package/package.json +106 -0
  40. package/react-native-image-compression-kit.podspec +21 -0
  41. package/react-native.config.js +12 -0
  42. package/src/NativeImageCompressionKit.ts +81 -0
  43. package/src/api.ts +28 -0
  44. package/src/errors.ts +91 -0
  45. package/src/index.ts +25 -0
  46. package/src/nativeModule.ts +130 -0
  47. package/src/types.ts +88 -0
  48. package/src/validation.ts +181 -0
@@ -0,0 +1,1127 @@
1
+ package com.imagecompressionkit
2
+
3
+ import android.annotation.TargetApi
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.ImageDecoder
7
+ import android.graphics.Matrix
8
+ import android.net.Uri
9
+ import android.os.Build
10
+ import android.provider.OpenableColumns
11
+ import androidx.exifinterface.media.ExifInterface
12
+ import com.facebook.react.bridge.Arguments
13
+ import com.facebook.react.bridge.Promise
14
+ import com.facebook.react.bridge.ReactApplicationContext
15
+ import com.facebook.react.bridge.ReadableMap
16
+ import com.facebook.react.bridge.WritableArray
17
+ import com.facebook.react.bridge.WritableMap
18
+ import java.io.File
19
+ import java.io.FileInputStream
20
+ import java.io.InputStream
21
+ import kotlin.math.roundToInt
22
+
23
+ class ImageCompressionKitModule(
24
+ private val reactContext: ReactApplicationContext,
25
+ private val writableMapFactory: () -> WritableMap = { Arguments.createMap() },
26
+ private val writableArrayFactory: () -> WritableArray = { Arguments.createArray() }
27
+ ) : NativeImageCompressionKitSpec(reactContext) {
28
+ override fun getName(): String = NAME
29
+
30
+ override fun compressImage(options: ReadableMap, promise: Promise) {
31
+ try {
32
+ val source = readMap(options, "source")
33
+ val output = readMap(options, "output")
34
+
35
+ if (source == null || output == null) {
36
+ reject(
37
+ promise,
38
+ ERR_INVALID_OPTIONS,
39
+ "Compression options must include source and output objects."
40
+ )
41
+ return
42
+ }
43
+
44
+ val resize = try {
45
+ readResizeOptions(readMap(options, "resize"))
46
+ } catch (error: InvalidOptionsException) {
47
+ reject(
48
+ promise,
49
+ ERR_INVALID_OPTIONS,
50
+ error.message ?: "Compression resize options are invalid.",
51
+ error
52
+ )
53
+ return
54
+ }
55
+
56
+ val outputFormat = try {
57
+ readOutputFormat(output)
58
+ } catch (error: InvalidOptionsException) {
59
+ reject(
60
+ promise,
61
+ ERR_INVALID_OPTIONS,
62
+ error.message ?: "Compression output.format is invalid.",
63
+ error
64
+ )
65
+ return
66
+ }
67
+
68
+ if (outputFormat == null) {
69
+ reject(
70
+ promise,
71
+ ERR_NOT_IMPLEMENTED,
72
+ "Android MVP supports JPEG, PNG, WebP, GIF, HEIC, HEIF, and AVIF input with JPEG, PNG, and WebP output only."
73
+ )
74
+ return
75
+ }
76
+
77
+ val metadataPolicy = try {
78
+ readMetadataPolicy(options)
79
+ } catch (error: InvalidOptionsException) {
80
+ reject(
81
+ promise,
82
+ ERR_INVALID_OPTIONS,
83
+ error.message ?: "Compression metadata policy is invalid.",
84
+ error
85
+ )
86
+ return
87
+ }
88
+
89
+ val maxBytes = try {
90
+ readMaxBytes(output)
91
+ } catch (error: InvalidOptionsException) {
92
+ reject(
93
+ promise,
94
+ ERR_INVALID_OPTIONS,
95
+ error.message ?: "Compression output.maxBytes is invalid.",
96
+ error
97
+ )
98
+ return
99
+ }
100
+
101
+ val maxBytesValidationError = ImageCompressionOutput.maxBytesValidationError(
102
+ outputFormat,
103
+ maxBytes
104
+ )
105
+ if (maxBytesValidationError != null) {
106
+ reject(
107
+ promise,
108
+ ERR_INVALID_OPTIONS,
109
+ maxBytesValidationError
110
+ )
111
+ return
112
+ }
113
+
114
+ val quality = readQuality(output)
115
+ val uri = if (hasValue(source, "uri")) {
116
+ source.getString("uri")
117
+ } else {
118
+ null
119
+ }
120
+ if (uri.isNullOrBlank()) {
121
+ reject(
122
+ promise,
123
+ ERR_INVALID_OPTIONS,
124
+ "Compression source.uri must be a non-empty string."
125
+ )
126
+ return
127
+ }
128
+
129
+ val inputSource = inputSourceFromUri(uri)
130
+ if (inputSource == null) {
131
+ reject(
132
+ promise,
133
+ ERR_UNSUPPORTED_SOURCE,
134
+ "Android MVP supports file:// and content:// image URIs only."
135
+ )
136
+ return
137
+ }
138
+
139
+ val originalByteSize = try {
140
+ readOriginalByteSize(inputSource)
141
+ } catch (error: SourceAccessException) {
142
+ reject(
143
+ promise,
144
+ ERR_FILE_ACCESS,
145
+ error.message ?: "Android MVP could not read the source image URI.",
146
+ error
147
+ )
148
+ return
149
+ }
150
+ val unsupportedInputMimeTypeHint = readUnsupportedInputMimeTypeHint(inputSource)
151
+
152
+ if (unsupportedInputMimeTypeHint != null) {
153
+ reject(
154
+ promise,
155
+ ERR_UNSUPPORTED_FORMAT,
156
+ unsupportedInputFormatMessage(unsupportedInputMimeTypeHint)
157
+ )
158
+ return
159
+ }
160
+ val inputFormatHint = readInputFormatHint(inputSource)
161
+
162
+ val bounds = try {
163
+ decodeBounds(inputSource)
164
+ } catch (error: SourceAccessException) {
165
+ reject(
166
+ promise,
167
+ ERR_FILE_ACCESS,
168
+ error.message ?: "Android MVP could not read the source image URI.",
169
+ error
170
+ )
171
+ return
172
+ }
173
+
174
+ val inputFormat = InputFormat.fromMimeType(bounds?.mimeType) ?: inputFormatHint
175
+ if (inputFormat == null) {
176
+ val errorCode = if (bounds == null) {
177
+ ERR_DECODE_FAILED
178
+ } else {
179
+ ERR_UNSUPPORTED_FORMAT
180
+ }
181
+ val errorMessage = if (bounds == null) {
182
+ "Android MVP could not decode the source image."
183
+ } else {
184
+ DEFAULT_UNSUPPORTED_INPUT_FORMAT_MESSAGE
185
+ }
186
+
187
+ reject(
188
+ promise,
189
+ errorCode,
190
+ errorMessage
191
+ )
192
+ return
193
+ }
194
+
195
+ val bitmap = try {
196
+ decodeBitmap(inputSource, inputFormat)
197
+ } catch (error: SourceAccessException) {
198
+ reject(
199
+ promise,
200
+ ERR_FILE_ACCESS,
201
+ error.message ?: "Android MVP could not read the source image URI.",
202
+ error
203
+ )
204
+ return
205
+ }
206
+
207
+ if (bitmap == null) {
208
+ reject(
209
+ promise,
210
+ ERR_DECODE_FAILED,
211
+ "Android MVP could not decode the source image."
212
+ )
213
+ return
214
+ }
215
+
216
+ val exifOrientation = if (inputFormat.supportsJpegExifMetadata) {
217
+ readExifOrientation(inputSource)
218
+ } else {
219
+ ExifInterface.ORIENTATION_NORMAL
220
+ }
221
+ val orientedBitmap = applyExifOrientation(bitmap, exifOrientation)
222
+ val processedBitmap = resizeBitmap(orientedBitmap, resize)
223
+ val outputDimensions = ImageDimensions(
224
+ width = processedBitmap.width,
225
+ height = processedBitmap.height
226
+ )
227
+ val outputFile = ImageCompressionOutput.createOutputFile(
228
+ reactContext.cacheDir,
229
+ outputFormat
230
+ )
231
+ val didEncode: Boolean
232
+
233
+ try {
234
+ val copiedExifMetadata = if (
235
+ inputFormat.supportsJpegExifMetadata &&
236
+ outputFormat.supportsJpegExifMetadata
237
+ ) {
238
+ createCopiedExifMetadata(
239
+ metadataPolicy,
240
+ inputSource,
241
+ outputDimensions
242
+ )
243
+ } else {
244
+ null
245
+ }
246
+
247
+ didEncode = ImageCompressionOutput.encodeBitmap(
248
+ processedBitmap,
249
+ outputFile,
250
+ outputFormat,
251
+ quality,
252
+ maxBytes,
253
+ copiedExifMetadata
254
+ )
255
+ } catch (error: MetadataCopyException) {
256
+ reject(
257
+ promise,
258
+ ERR_ENCODE_FAILED,
259
+ error.message ?: "Android MVP could not copy JPEG metadata.",
260
+ error
261
+ )
262
+ return
263
+ } catch (error: Exception) {
264
+ reject(
265
+ promise,
266
+ ERR_ENCODE_FAILED,
267
+ error.message ?: "Android MVP could not encode the selected output format.",
268
+ error
269
+ )
270
+ return
271
+ } finally {
272
+ if (processedBitmap !== orientedBitmap) {
273
+ processedBitmap.recycle()
274
+ }
275
+ if (orientedBitmap !== bitmap) {
276
+ orientedBitmap.recycle()
277
+ }
278
+ bitmap.recycle()
279
+ }
280
+
281
+ if (!didEncode) {
282
+ reject(
283
+ promise,
284
+ ERR_ENCODE_FAILED,
285
+ "Android MVP could not encode the selected output format."
286
+ )
287
+ return
288
+ }
289
+
290
+ promise.resolve(
291
+ createCompressionResult(
292
+ originalByteSize,
293
+ outputFile,
294
+ outputDimensions,
295
+ outputFormat
296
+ )
297
+ )
298
+ } catch (error: Exception) {
299
+ reject(
300
+ promise,
301
+ ERR_NATIVE_OPERATION_FAILED,
302
+ "Android MVP compression failed.",
303
+ error
304
+ )
305
+ }
306
+ }
307
+
308
+ override fun getImageCompressionCapabilities(promise: Promise) {
309
+ promise.resolve(createStubCapabilities())
310
+ }
311
+
312
+ private fun createStubCapabilities(): WritableMap =
313
+ writableMapFactory().apply {
314
+ putString("platform", "android")
315
+ putArray("formats", createFormatCapabilities())
316
+ putArray("metadataPolicies", createMetadataPolicies())
317
+ putBoolean("supportsTargetSizeCompression", true)
318
+ putBoolean("supportsCancellation", false)
319
+ }
320
+
321
+ private fun createFormatCapabilities(): WritableArray =
322
+ writableArrayFactory().apply {
323
+ ImageCompressionOutput.FORMAT_VALUES.forEach { format ->
324
+ pushMap(createFormatCapability(format))
325
+ }
326
+ }
327
+
328
+ private fun createFormatCapability(format: String): WritableMap {
329
+ val capability = ImageCompressionOutput.createFormatCapability(format)
330
+
331
+ return writableMapFactory().apply {
332
+ putString("format", capability.format)
333
+ putBoolean("input", capability.input)
334
+ putBoolean("output", capability.output)
335
+ putBoolean("supportsAlpha", capability.supportsAlpha)
336
+ putBoolean("supportsAnimation", capability.supportsAnimation)
337
+ putArray("notes", createStringArray(capability.notes))
338
+ }
339
+ }
340
+
341
+ private fun createMetadataPolicies(): WritableArray =
342
+ writableArrayFactory().apply {
343
+ pushString(METADATA_POLICY_PRESERVE)
344
+ pushString(METADATA_POLICY_SAFE)
345
+ pushString(METADATA_POLICY_STRIP)
346
+ }
347
+
348
+ private fun createStringArray(values: List<String>): WritableArray =
349
+ writableArrayFactory().apply {
350
+ values.forEach { value ->
351
+ pushString(value)
352
+ }
353
+ }
354
+
355
+ private fun hasValue(map: ReadableMap, key: String): Boolean =
356
+ map.hasKey(key) && !map.isNull(key)
357
+
358
+ private fun readMap(map: ReadableMap, key: String): ReadableMap? =
359
+ if (hasValue(map, key)) {
360
+ map.getMap(key)
361
+ } else {
362
+ null
363
+ }
364
+
365
+ private fun readQuality(output: ReadableMap): Int =
366
+ if (hasValue(output, "quality")) {
367
+ output.getDouble("quality").toInt().coerceIn(MIN_QUALITY, MAX_QUALITY)
368
+ } else {
369
+ DEFAULT_QUALITY
370
+ }
371
+
372
+ private fun readOutputFormat(output: ReadableMap): OutputFormat? {
373
+ val value = if (hasValue(output, "format")) {
374
+ try {
375
+ output.getString("format")
376
+ } catch (error: Exception) {
377
+ throw InvalidOptionsException(
378
+ "Compression output.format must be one of: jpeg, png, webp, heic, heif, avif.",
379
+ error
380
+ )
381
+ }
382
+ } else {
383
+ throw InvalidOptionsException(
384
+ "Compression output.format must be one of: jpeg, png, webp, heic, heif, avif."
385
+ )
386
+ }
387
+
388
+ return ImageCompressionOutput.fromValue(value)
389
+ }
390
+
391
+ private fun readMetadataPolicy(options: ReadableMap): MetadataPolicy {
392
+ val value = if (hasValue(options, "metadata")) {
393
+ try {
394
+ options.getString("metadata")
395
+ } catch (error: Exception) {
396
+ throw InvalidOptionsException(
397
+ "Compression metadata must be one of: preserve, safe, strip.",
398
+ error
399
+ )
400
+ }
401
+ } else {
402
+ METADATA_POLICY_SAFE
403
+ }
404
+
405
+ return when (value) {
406
+ METADATA_POLICY_SAFE -> MetadataPolicy.SAFE
407
+ METADATA_POLICY_STRIP -> MetadataPolicy.STRIP
408
+ METADATA_POLICY_PRESERVE -> MetadataPolicy.PRESERVE
409
+ else -> throw InvalidOptionsException(
410
+ "Compression metadata must be one of: preserve, safe, strip."
411
+ )
412
+ }
413
+ }
414
+
415
+ private fun readMaxBytes(output: ReadableMap): Long? {
416
+ if (!hasValue(output, "maxBytes")) {
417
+ return null
418
+ }
419
+
420
+ val value = try {
421
+ output.getDouble("maxBytes")
422
+ } catch (error: Exception) {
423
+ throw InvalidOptionsException("Compression output.maxBytes must be a positive integer.", error)
424
+ }
425
+
426
+ if (
427
+ value.isNaN() ||
428
+ value.isInfinite() ||
429
+ value <= 0.0 ||
430
+ value > MAX_SAFE_INTEGER ||
431
+ value.toLong().toDouble() != value
432
+ ) {
433
+ throw InvalidOptionsException("Compression output.maxBytes must be a positive integer.")
434
+ }
435
+
436
+ return value.toLong()
437
+ }
438
+
439
+ private fun readResizeOptions(resize: ReadableMap?): ResizeOptions? {
440
+ if (resize == null) {
441
+ return null
442
+ }
443
+
444
+ val maxWidth = readOptionalPositiveInteger(resize, "maxWidth")
445
+ val maxHeight = readOptionalPositiveInteger(resize, "maxHeight")
446
+
447
+ if (maxWidth == null && maxHeight == null) {
448
+ throw InvalidOptionsException(
449
+ "Compression resize must include maxWidth, maxHeight, or both."
450
+ )
451
+ }
452
+
453
+ val modeValue = if (hasValue(resize, "mode")) {
454
+ resize.getString("mode")
455
+ } else {
456
+ RESIZE_MODE_CONTAIN
457
+ }
458
+
459
+ val mode = when (modeValue) {
460
+ RESIZE_MODE_CONTAIN -> ResizeMode.CONTAIN
461
+ RESIZE_MODE_COVER -> ResizeMode.COVER
462
+ RESIZE_MODE_STRETCH -> ResizeMode.STRETCH
463
+ else -> throw InvalidOptionsException(
464
+ "Compression resize.mode must be one of: contain, cover, stretch."
465
+ )
466
+ }
467
+
468
+ return ResizeOptions(maxWidth = maxWidth, maxHeight = maxHeight, mode = mode)
469
+ }
470
+
471
+ private fun readOptionalPositiveInteger(map: ReadableMap, key: String): Int? {
472
+ if (!hasValue(map, key)) {
473
+ return null
474
+ }
475
+
476
+ val value = try {
477
+ map.getDouble(key)
478
+ } catch (error: Exception) {
479
+ throw InvalidOptionsException("Compression resize.$key must be a positive integer.", error)
480
+ }
481
+
482
+ if (
483
+ value.isNaN() ||
484
+ value.isInfinite() ||
485
+ value <= 0.0 ||
486
+ value.toInt().toDouble() != value
487
+ ) {
488
+ throw InvalidOptionsException("Compression resize.$key must be a positive integer.")
489
+ }
490
+
491
+ return value.toInt()
492
+ }
493
+
494
+ private fun inputSourceFromUri(uri: String): ImageInputSource? {
495
+ val parsed = Uri.parse(uri)
496
+
497
+ return when (parsed.scheme?.lowercase()) {
498
+ "file" -> {
499
+ val path = parsed.path ?: return null
500
+ ImageInputSource.FileSource(parsed, File(path))
501
+ }
502
+ "content" -> ImageInputSource.ContentSource(parsed)
503
+ else -> null
504
+ }
505
+ }
506
+
507
+ private fun readOriginalByteSize(inputSource: ImageInputSource): Long =
508
+ when (inputSource) {
509
+ is ImageInputSource.FileSource -> {
510
+ val inputFile = inputSource.file
511
+
512
+ if (!inputFile.exists() || !inputFile.isFile || !inputFile.canRead()) {
513
+ throw SourceAccessException("Android MVP could not read the source file.")
514
+ }
515
+
516
+ inputFile.length()
517
+ }
518
+ is ImageInputSource.ContentSource ->
519
+ queryContentByteSize(inputSource.uri)
520
+ ?: queryContentAssetLength(inputSource.uri)
521
+ ?: countBytes(inputSource)
522
+ }
523
+
524
+ private fun queryContentByteSize(uri: Uri): Long? =
525
+ try {
526
+ reactContext.contentResolver.query(
527
+ uri,
528
+ arrayOf(OpenableColumns.SIZE),
529
+ null,
530
+ null,
531
+ null
532
+ )?.use { cursor ->
533
+ val sizeColumnIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
534
+
535
+ if (
536
+ sizeColumnIndex >= 0 &&
537
+ cursor.moveToFirst() &&
538
+ !cursor.isNull(sizeColumnIndex)
539
+ ) {
540
+ val size = cursor.getLong(sizeColumnIndex)
541
+ if (size >= 0L) {
542
+ size
543
+ } else {
544
+ null
545
+ }
546
+ } else {
547
+ null
548
+ }
549
+ }
550
+ } catch (_: Exception) {
551
+ null
552
+ }
553
+
554
+ private fun queryContentAssetLength(uri: Uri): Long? =
555
+ try {
556
+ reactContext.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
557
+ if (descriptor.length >= 0L) {
558
+ descriptor.length
559
+ } else {
560
+ null
561
+ }
562
+ }
563
+ } catch (_: Exception) {
564
+ null
565
+ }
566
+
567
+ private fun countBytes(inputSource: ImageInputSource): Long =
568
+ openInputStream(inputSource).use { inputStream ->
569
+ val buffer = ByteArray(STREAM_BUFFER_SIZE)
570
+ var totalBytes = 0L
571
+ var bytesRead = inputStream.read(buffer)
572
+
573
+ while (bytesRead != -1) {
574
+ totalBytes += bytesRead.toLong()
575
+ bytesRead = inputStream.read(buffer)
576
+ }
577
+
578
+ totalBytes
579
+ }
580
+
581
+ private fun decodeBounds(inputSource: ImageInputSource): ImageBounds? {
582
+ val options = BitmapFactory.Options().apply {
583
+ inJustDecodeBounds = true
584
+ }
585
+
586
+ openInputStream(inputSource).buffered().use { inputStream ->
587
+ BitmapFactory.decodeStream(inputStream, null, options)
588
+ }
589
+
590
+ if (options.outWidth <= 0 || options.outHeight <= 0) {
591
+ return null
592
+ }
593
+
594
+ return ImageBounds(
595
+ width = options.outWidth,
596
+ height = options.outHeight,
597
+ mimeType = options.outMimeType
598
+ )
599
+ }
600
+
601
+ private fun decodeBitmap(
602
+ inputSource: ImageInputSource,
603
+ inputFormat: InputFormat
604
+ ): Bitmap? =
605
+ when {
606
+ inputFormat.usesAvifDecodePath -> decodeAvifBitmap(inputSource)
607
+ inputFormat.usesHeifDecodePath -> decodeHeicHeifBitmap(inputSource)
608
+ else -> decodeBitmapFactory(inputSource)
609
+ }
610
+
611
+ private fun decodeBitmapFactory(inputSource: ImageInputSource): Bitmap? =
612
+ openInputStream(inputSource).buffered().use { inputStream ->
613
+ BitmapFactory.decodeStream(inputStream)
614
+ }
615
+
616
+ private fun decodeHeicHeifBitmap(inputSource: ImageInputSource): Bitmap? =
617
+ when {
618
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ->
619
+ decodeHeicHeifBitmapWithImageDecoder(inputSource)
620
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ->
621
+ decodeBitmapFactory(inputSource)
622
+ else -> null
623
+ }
624
+
625
+ @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
626
+ private fun decodeAvifBitmap(inputSource: ImageInputSource): Bitmap? =
627
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
628
+ decodeAvifBitmapWithImageDecoder(inputSource)
629
+ } else {
630
+ null
631
+ }
632
+
633
+ @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
634
+ private fun decodeAvifBitmapWithImageDecoder(inputSource: ImageInputSource): Bitmap? =
635
+ try {
636
+ ImageDecoder.decodeBitmap(createImageDecoderSource(inputSource)) { decoder, _, _ ->
637
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
638
+ }
639
+ } catch (_: Exception) {
640
+ null
641
+ }
642
+
643
+ @TargetApi(Build.VERSION_CODES.P)
644
+ private fun decodeHeicHeifBitmapWithImageDecoder(inputSource: ImageInputSource): Bitmap? =
645
+ try {
646
+ ImageDecoder.decodeBitmap(createImageDecoderSource(inputSource)) { decoder, _, _ ->
647
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
648
+ }
649
+ } catch (_: Exception) {
650
+ null
651
+ }
652
+
653
+ @TargetApi(Build.VERSION_CODES.P)
654
+ private fun createImageDecoderSource(inputSource: ImageInputSource): ImageDecoder.Source =
655
+ when (inputSource) {
656
+ is ImageInputSource.FileSource -> ImageDecoder.createSource(inputSource.file)
657
+ is ImageInputSource.ContentSource ->
658
+ ImageDecoder.createSource(reactContext.contentResolver, inputSource.uri)
659
+ }
660
+
661
+ private fun readUnsupportedInputMimeTypeHint(inputSource: ImageInputSource): String? {
662
+ val contentMimeType = when (inputSource) {
663
+ is ImageInputSource.FileSource -> null
664
+ is ImageInputSource.ContentSource -> queryContentMimeType(inputSource.uri)
665
+ }
666
+
667
+ InputFormat.fromMimeType(contentMimeType)?.let { inputFormat ->
668
+ if (!canDecodeInputFormat(inputFormat)) {
669
+ return inputFormat.mimeType
670
+ }
671
+ return null
672
+ }
673
+
674
+ val fileExtension = when (inputSource) {
675
+ is ImageInputSource.FileSource -> inputSource.file.extension
676
+ is ImageInputSource.ContentSource ->
677
+ inputSource.uri.lastPathSegment?.substringAfterLast('.', "")
678
+ }
679
+
680
+ val extensionInputFormat = InputFormat.fromFileExtension(fileExtension)
681
+
682
+ return if (extensionInputFormat != null && !canDecodeInputFormat(extensionInputFormat)) {
683
+ extensionInputFormat.mimeType
684
+ } else {
685
+ null
686
+ }
687
+ }
688
+
689
+ private fun readInputFormatHint(inputSource: ImageInputSource): InputFormat? {
690
+ val contentMimeType = when (inputSource) {
691
+ is ImageInputSource.FileSource -> null
692
+ is ImageInputSource.ContentSource -> queryContentMimeType(inputSource.uri)
693
+ }
694
+
695
+ InputFormat.fromMimeType(contentMimeType)?.let { inputFormat ->
696
+ return inputFormat
697
+ }
698
+
699
+ val fileExtension = when (inputSource) {
700
+ is ImageInputSource.FileSource -> inputSource.file.extension
701
+ is ImageInputSource.ContentSource ->
702
+ inputSource.uri.lastPathSegment?.substringAfterLast('.', "")
703
+ }
704
+
705
+ return InputFormat.fromFileExtension(fileExtension)
706
+ }
707
+
708
+ private fun canDecodeInputFormat(inputFormat: InputFormat): Boolean =
709
+ when {
710
+ inputFormat.usesAvifDecodePath ->
711
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
712
+ inputFormat.usesHeifDecodePath ->
713
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
714
+ else -> true
715
+ }
716
+
717
+ private fun unsupportedInputFormatMessage(mimeTypeHint: String): String {
718
+ val inputFormat = InputFormat.fromMimeType(mimeTypeHint)
719
+
720
+ return when {
721
+ inputFormat?.usesHeifDecodePath == true ->
722
+ "Android HEIC/HEIF input requires Android 8.0+ platform decoder support."
723
+ inputFormat?.usesAvifDecodePath == true ->
724
+ "Android AVIF input requires Android 14+ platform decoder support."
725
+ else -> DEFAULT_UNSUPPORTED_INPUT_FORMAT_MESSAGE
726
+ }
727
+ }
728
+
729
+ private fun queryContentMimeType(uri: Uri): String? =
730
+ try {
731
+ reactContext.contentResolver.getType(uri)
732
+ } catch (_: Exception) {
733
+ null
734
+ }
735
+
736
+ private fun readExifOrientation(inputSource: ImageInputSource): Int =
737
+ try {
738
+ openInputStream(inputSource).buffered().use { inputStream ->
739
+ ExifInterface(inputStream).getAttributeInt(
740
+ ExifInterface.TAG_ORIENTATION,
741
+ ExifInterface.ORIENTATION_NORMAL
742
+ )
743
+ }
744
+ } catch (_: Exception) {
745
+ ExifInterface.ORIENTATION_NORMAL
746
+ }
747
+
748
+ private fun createCopiedExifMetadata(
749
+ metadataPolicy: MetadataPolicy,
750
+ inputSource: ImageInputSource,
751
+ dimensions: ImageDimensions
752
+ ): CopiedExifMetadata? {
753
+ val exifTags = when (metadataPolicy) {
754
+ MetadataPolicy.PRESERVE -> JpegExifMetadata.PRESERVED_EXIF_TAGS
755
+ MetadataPolicy.SAFE -> JpegExifMetadata.SAFE_EXIF_TAGS
756
+ MetadataPolicy.STRIP -> return null
757
+ }
758
+
759
+ try {
760
+ openInputStream(inputSource).buffered().use { inputStream ->
761
+ return JpegExifMetadata.read(
762
+ inputStream = inputStream,
763
+ exifTags = exifTags,
764
+ width = dimensions.width,
765
+ height = dimensions.height
766
+ )
767
+ }
768
+ } catch (error: Exception) {
769
+ throw MetadataCopyException(
770
+ "Android MVP could not read source EXIF metadata.",
771
+ error
772
+ )
773
+ }
774
+ }
775
+
776
+ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
777
+ val matrix = createExifOrientationMatrix(orientation) ?: return bitmap
778
+
779
+ return Bitmap.createBitmap(
780
+ bitmap,
781
+ 0,
782
+ 0,
783
+ bitmap.width,
784
+ bitmap.height,
785
+ matrix,
786
+ true
787
+ )
788
+ }
789
+
790
+ private fun createExifOrientationMatrix(orientation: Int): Matrix? {
791
+ val matrix = Matrix()
792
+
793
+ when (orientation) {
794
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL ->
795
+ matrix.setScale(-1f, 1f)
796
+ ExifInterface.ORIENTATION_ROTATE_180 ->
797
+ matrix.setRotate(180f)
798
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
799
+ matrix.setRotate(180f)
800
+ matrix.postScale(-1f, 1f)
801
+ }
802
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
803
+ matrix.setRotate(90f)
804
+ matrix.postScale(-1f, 1f)
805
+ }
806
+ ExifInterface.ORIENTATION_ROTATE_90 ->
807
+ matrix.setRotate(90f)
808
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
809
+ matrix.setRotate(-90f)
810
+ matrix.postScale(-1f, 1f)
811
+ }
812
+ ExifInterface.ORIENTATION_ROTATE_270 ->
813
+ matrix.setRotate(-90f)
814
+ else -> return null
815
+ }
816
+
817
+ return matrix
818
+ }
819
+
820
+ private fun resizeBitmap(bitmap: Bitmap, resize: ResizeOptions?): Bitmap {
821
+ if (resize == null) {
822
+ return bitmap
823
+ }
824
+
825
+ return when (resize.mode) {
826
+ ResizeMode.CONTAIN -> resizeContain(bitmap, resize)
827
+ ResizeMode.COVER -> resizeCover(bitmap, resize)
828
+ ResizeMode.STRETCH -> resizeStretch(bitmap, resize)
829
+ }
830
+ }
831
+
832
+ private fun resizeContain(bitmap: Bitmap, resize: ResizeOptions): Bitmap {
833
+ val scale = minOf(
834
+ resize.maxWidth?.let { it.toDouble() / bitmap.width.toDouble() } ?: 1.0,
835
+ resize.maxHeight?.let { it.toDouble() / bitmap.height.toDouble() } ?: 1.0,
836
+ 1.0
837
+ )
838
+
839
+ return createScaledBitmapIfNeeded(
840
+ bitmap,
841
+ scaledDimension(bitmap.width, scale),
842
+ scaledDimension(bitmap.height, scale)
843
+ )
844
+ }
845
+
846
+ private fun resizeCover(bitmap: Bitmap, resize: ResizeOptions): Bitmap {
847
+ val maxWidth = resize.maxWidth
848
+ val maxHeight = resize.maxHeight
849
+
850
+ if (maxWidth == null || maxHeight == null) {
851
+ return resizeContain(bitmap, resize)
852
+ }
853
+
854
+ val targetWidth = maxWidth.coerceAtMost(bitmap.width)
855
+ val targetHeight = maxHeight.coerceAtMost(bitmap.height)
856
+ val scale = minOf(
857
+ maxOf(
858
+ targetWidth.toDouble() / bitmap.width.toDouble(),
859
+ targetHeight.toDouble() / bitmap.height.toDouble()
860
+ ),
861
+ 1.0
862
+ )
863
+ val scaled = createScaledBitmapIfNeeded(
864
+ bitmap,
865
+ scaledDimension(bitmap.width, scale),
866
+ scaledDimension(bitmap.height, scale)
867
+ )
868
+ val cropped = centerCropBitmap(
869
+ scaled,
870
+ targetWidth.coerceAtMost(scaled.width),
871
+ targetHeight.coerceAtMost(scaled.height)
872
+ )
873
+
874
+ if (cropped !== scaled && scaled !== bitmap) {
875
+ scaled.recycle()
876
+ }
877
+
878
+ return cropped
879
+ }
880
+
881
+ private fun resizeStretch(bitmap: Bitmap, resize: ResizeOptions): Bitmap {
882
+ val targetWidth = resize.maxWidth?.coerceAtMost(bitmap.width) ?: bitmap.width
883
+ val targetHeight = resize.maxHeight?.coerceAtMost(bitmap.height) ?: bitmap.height
884
+
885
+ return createScaledBitmapIfNeeded(bitmap, targetWidth, targetHeight)
886
+ }
887
+
888
+ private fun createScaledBitmapIfNeeded(
889
+ bitmap: Bitmap,
890
+ targetWidth: Int,
891
+ targetHeight: Int
892
+ ): Bitmap {
893
+ if (bitmap.width == targetWidth && bitmap.height == targetHeight) {
894
+ return bitmap
895
+ }
896
+
897
+ return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
898
+ }
899
+
900
+ private fun centerCropBitmap(
901
+ bitmap: Bitmap,
902
+ targetWidth: Int,
903
+ targetHeight: Int
904
+ ): Bitmap {
905
+ if (bitmap.width == targetWidth && bitmap.height == targetHeight) {
906
+ return bitmap
907
+ }
908
+
909
+ val x = ((bitmap.width - targetWidth) / 2).coerceAtLeast(0)
910
+ val y = ((bitmap.height - targetHeight) / 2).coerceAtLeast(0)
911
+
912
+ return Bitmap.createBitmap(bitmap, x, y, targetWidth, targetHeight)
913
+ }
914
+
915
+ private fun scaledDimension(value: Int, scale: Double): Int =
916
+ (value.toDouble() * scale).roundToInt().coerceAtLeast(1)
917
+
918
+ private fun openInputStream(inputSource: ImageInputSource): InputStream =
919
+ try {
920
+ when (inputSource) {
921
+ is ImageInputSource.FileSource -> FileInputStream(inputSource.file)
922
+ is ImageInputSource.ContentSource ->
923
+ reactContext.contentResolver.openInputStream(inputSource.uri)
924
+ ?: throw SourceAccessException(
925
+ "Android MVP could not open the source content URI."
926
+ )
927
+ }
928
+ } catch (error: SourceAccessException) {
929
+ throw error
930
+ } catch (error: Exception) {
931
+ throw SourceAccessException(
932
+ "Android MVP could not read the source image URI.",
933
+ error
934
+ )
935
+ }
936
+
937
+ private fun createCompressionResult(
938
+ originalByteSize: Long,
939
+ outputFile: File,
940
+ dimensions: ImageDimensions,
941
+ outputFormat: OutputFormat
942
+ ): WritableMap {
943
+ val outputResult = ImageCompressionOutput.createResultMetadata(
944
+ originalByteSize = originalByteSize,
945
+ outputFile = outputFile,
946
+ dimensions = CompressionOutputDimensions(
947
+ width = dimensions.width,
948
+ height = dimensions.height
949
+ ),
950
+ outputFormat = outputFormat
951
+ )
952
+
953
+ return writableMapFactory().apply {
954
+ putString("uri", outputResult.uri)
955
+ putString("format", outputResult.format)
956
+ putInt("width", outputResult.width)
957
+ putInt("height", outputResult.height)
958
+ putDouble("byteSize", outputResult.byteSize.toDouble())
959
+ putDouble("originalByteSize", outputResult.originalByteSize.toDouble())
960
+ putDouble("compressionRatio", outputResult.compressionRatio)
961
+ }
962
+ }
963
+
964
+ private fun reject(
965
+ promise: Promise,
966
+ code: String,
967
+ message: String,
968
+ throwable: Throwable? = null
969
+ ) {
970
+ promise.reject(code, message, throwable)
971
+ }
972
+
973
+ private data class ImageBounds(
974
+ val width: Int,
975
+ val height: Int,
976
+ val mimeType: String?
977
+ )
978
+
979
+ private data class ImageDimensions(
980
+ val width: Int,
981
+ val height: Int
982
+ )
983
+
984
+ private data class ResizeOptions(
985
+ val maxWidth: Int?,
986
+ val maxHeight: Int?,
987
+ val mode: ResizeMode
988
+ )
989
+
990
+ private enum class ResizeMode {
991
+ CONTAIN,
992
+ COVER,
993
+ STRETCH
994
+ }
995
+
996
+ private enum class MetadataPolicy {
997
+ PRESERVE,
998
+ SAFE,
999
+ STRIP
1000
+ }
1001
+
1002
+ private enum class InputFormat(
1003
+ val mimeType: String,
1004
+ val mimeTypeAliases: Set<String>,
1005
+ val fileExtensions: Set<String>,
1006
+ val supportsJpegExifMetadata: Boolean,
1007
+ val usesHeifDecodePath: Boolean = false,
1008
+ val usesAvifDecodePath: Boolean = false
1009
+ ) {
1010
+ JPEG(
1011
+ mimeType = "image/jpeg",
1012
+ mimeTypeAliases = emptySet(),
1013
+ fileExtensions = setOf("jpg", "jpeg"),
1014
+ supportsJpegExifMetadata = true
1015
+ ),
1016
+ PNG(
1017
+ mimeType = "image/png",
1018
+ mimeTypeAliases = emptySet(),
1019
+ fileExtensions = setOf("png"),
1020
+ supportsJpegExifMetadata = false
1021
+ ),
1022
+ WEBP(
1023
+ mimeType = "image/webp",
1024
+ mimeTypeAliases = emptySet(),
1025
+ fileExtensions = setOf("webp"),
1026
+ supportsJpegExifMetadata = false
1027
+ ),
1028
+ GIF(
1029
+ mimeType = "image/gif",
1030
+ mimeTypeAliases = emptySet(),
1031
+ fileExtensions = setOf("gif"),
1032
+ supportsJpegExifMetadata = false
1033
+ ),
1034
+ HEIC(
1035
+ mimeType = "image/heic",
1036
+ mimeTypeAliases = setOf("image/heic-sequence"),
1037
+ fileExtensions = setOf("heic"),
1038
+ supportsJpegExifMetadata = false,
1039
+ usesHeifDecodePath = true
1040
+ ),
1041
+ HEIF(
1042
+ mimeType = "image/heif",
1043
+ mimeTypeAliases = setOf("image/heif-sequence"),
1044
+ fileExtensions = setOf("heif"),
1045
+ supportsJpegExifMetadata = false,
1046
+ usesHeifDecodePath = true
1047
+ ),
1048
+ AVIF(
1049
+ mimeType = "image/avif",
1050
+ mimeTypeAliases = emptySet(),
1051
+ fileExtensions = setOf("avif"),
1052
+ supportsJpegExifMetadata = false,
1053
+ usesAvifDecodePath = true
1054
+ );
1055
+
1056
+ companion object {
1057
+ fun fromMimeType(mimeType: String?): InputFormat? {
1058
+ val normalizedMimeType = mimeType?.trim()?.lowercase() ?: return null
1059
+
1060
+ return values().firstOrNull {
1061
+ it.mimeType == normalizedMimeType || normalizedMimeType in it.mimeTypeAliases
1062
+ }
1063
+ }
1064
+
1065
+ fun fromFileExtension(fileExtension: String?): InputFormat? {
1066
+ val normalizedFileExtension = fileExtension?.trim()?.trimStart('.')?.lowercase()
1067
+ ?: return null
1068
+
1069
+ return values().firstOrNull { normalizedFileExtension in it.fileExtensions }
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ private sealed class ImageInputSource {
1075
+ abstract val uri: Uri
1076
+
1077
+ data class FileSource(
1078
+ override val uri: Uri,
1079
+ val file: File
1080
+ ) : ImageInputSource()
1081
+
1082
+ data class ContentSource(
1083
+ override val uri: Uri
1084
+ ) : ImageInputSource()
1085
+ }
1086
+
1087
+ private class SourceAccessException(
1088
+ message: String,
1089
+ cause: Throwable? = null
1090
+ ) : Exception(message, cause)
1091
+
1092
+ private class InvalidOptionsException(
1093
+ message: String,
1094
+ cause: Throwable? = null
1095
+ ) : Exception(message, cause)
1096
+
1097
+ private class MetadataCopyException(
1098
+ message: String,
1099
+ cause: Throwable? = null
1100
+ ) : Exception(message, cause)
1101
+
1102
+ companion object {
1103
+ const val NAME = "ImageCompressionKit"
1104
+ const val ERR_INVALID_OPTIONS = "ERR_INVALID_OPTIONS"
1105
+ const val ERR_UNSUPPORTED_SOURCE = "ERR_UNSUPPORTED_SOURCE"
1106
+ const val ERR_UNSUPPORTED_FORMAT = "ERR_UNSUPPORTED_FORMAT"
1107
+ const val ERR_NOT_IMPLEMENTED = "ERR_NOT_IMPLEMENTED"
1108
+ const val ERR_FILE_ACCESS = "ERR_FILE_ACCESS"
1109
+ const val ERR_DECODE_FAILED = "ERR_DECODE_FAILED"
1110
+ const val ERR_ENCODE_FAILED = "ERR_ENCODE_FAILED"
1111
+ const val ERR_NATIVE_OPERATION_FAILED = "ERR_NATIVE_OPERATION_FAILED"
1112
+
1113
+ private const val DEFAULT_QUALITY = 80
1114
+ private const val MIN_QUALITY = 0
1115
+ private const val MAX_QUALITY = 100
1116
+ private const val MAX_SAFE_INTEGER = 9007199254740991.0
1117
+ private const val STREAM_BUFFER_SIZE = 8 * 1024
1118
+ private const val RESIZE_MODE_CONTAIN = "contain"
1119
+ private const val RESIZE_MODE_COVER = "cover"
1120
+ private const val RESIZE_MODE_STRETCH = "stretch"
1121
+ private const val METADATA_POLICY_PRESERVE = "preserve"
1122
+ private const val METADATA_POLICY_SAFE = "safe"
1123
+ private const val METADATA_POLICY_STRIP = "strip"
1124
+ private const val DEFAULT_UNSUPPORTED_INPUT_FORMAT_MESSAGE =
1125
+ "Android MVP supports JPEG, PNG, WebP, GIF, HEIC, HEIF, and AVIF input only."
1126
+ }
1127
+ }