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.
- package/LICENSE +21 -0
- package/README.md +562 -0
- package/android/build.gradle +63 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/imagecompressionkit/ImageCompressionKitModule.kt +1127 -0
- package/android/src/main/java/com/imagecompressionkit/ImageCompressionKitPackage.kt +33 -0
- package/android/src/main/java/com/imagecompressionkit/ImageCompressionOutput.kt +396 -0
- package/android/src/main/java/com/imagecompressionkit/JpegExifMetadata.kt +233 -0
- package/ios/RCTImageCompressionKit.h +10 -0
- package/ios/RCTImageCompressionKit.mm +72 -0
- package/lib/NativeImageCompressionKit.d.ts +55 -0
- package/lib/NativeImageCompressionKit.d.ts.map +1 -0
- package/lib/NativeImageCompressionKit.js +5 -0
- package/lib/NativeImageCompressionKit.js.map +1 -0
- package/lib/api.d.ts +4 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +25 -0
- package/lib/api.js.map +1 -0
- package/lib/errors.d.ts +9 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +53 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +14 -0
- package/lib/index.js.map +1 -0
- package/lib/nativeModule.d.ts +7 -0
- package/lib/nativeModule.d.ts.map +1 -0
- package/lib/nativeModule.js +73 -0
- package/lib/nativeModule.js.map +1 -0
- package/lib/types.d.ts +59 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +23 -0
- package/lib/types.js.map +1 -0
- package/lib/validation.d.ts +3 -0
- package/lib/validation.d.ts.map +1 -0
- package/lib/validation.js +108 -0
- package/lib/validation.js.map +1 -0
- package/package.json +106 -0
- package/react-native-image-compression-kit.podspec +21 -0
- package/react-native.config.js +12 -0
- package/src/NativeImageCompressionKit.ts +81 -0
- package/src/api.ts +28 -0
- package/src/errors.ts +91 -0
- package/src/index.ts +25 -0
- package/src/nativeModule.ts +130 -0
- package/src/types.ts +88 -0
- 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
|
+
}
|