@sagepilot-ai/react-native-camera-addon 0.3.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.
@@ -0,0 +1,63 @@
1
+ package ai.sagepilot.reactnativecameraaddon;
2
+
3
+ import com.facebook.react.bridge.Arguments;
4
+ import com.facebook.react.bridge.ReactApplicationContext;
5
+ import com.facebook.react.bridge.WritableMap;
6
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
7
+ import java.lang.ref.WeakReference;
8
+
9
+ final class SagepilotInAppCameraDebugEvents {
10
+ static final String EVENT_NAME = "SagepilotInAppCameraDebug";
11
+
12
+ private static WeakReference<ReactApplicationContext> reactContextRef = new WeakReference<>(null);
13
+
14
+ /** Prevents construction of the static camera debug-event bridge. */
15
+ private SagepilotInAppCameraDebugEvents() {}
16
+
17
+ /** Registers the active React context used to emit native camera debug events. */
18
+ static void register(ReactApplicationContext reactContext) {
19
+ reactContextRef = new WeakReference<>(reactContext);
20
+ }
21
+
22
+ /** Clears the active React context when the owning module is invalidated. */
23
+ static void unregister(ReactApplicationContext reactContext) {
24
+ ReactApplicationContext activeContext = reactContextRef.get();
25
+ if (activeContext == reactContext) {
26
+ reactContextRef.clear();
27
+ }
28
+ }
29
+
30
+ /** Emits an informational native camera debug event to JavaScript. */
31
+ static void debug(String message) {
32
+ emit("debug", message, null);
33
+ }
34
+
35
+ /** Emits a warning native camera debug event to JavaScript. */
36
+ static void warn(String message) {
37
+ emit("warn", message, null);
38
+ }
39
+
40
+ /** Emits an error native camera debug event to JavaScript. */
41
+ static void error(String message, Throwable error) {
42
+ emit("error", message, error == null ? null : error.getMessage());
43
+ }
44
+
45
+ /** Emits a native camera debug event when a live React context is available. */
46
+ private static void emit(String level, String message, String errorMessage) {
47
+ ReactApplicationContext reactContext = reactContextRef.get();
48
+ if (reactContext == null || !reactContext.hasActiveCatalystInstance()) {
49
+ return;
50
+ }
51
+
52
+ WritableMap payload = Arguments.createMap();
53
+ payload.putString("level", level);
54
+ payload.putString("message", message);
55
+ if (errorMessage != null) {
56
+ payload.putString("error", errorMessage);
57
+ }
58
+
59
+ reactContext
60
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
61
+ .emit(EVENT_NAME, payload);
62
+ }
63
+ }
@@ -0,0 +1,278 @@
1
+ package ai.sagepilot.reactnativecameraaddon;
2
+
3
+ import android.graphics.Bitmap;
4
+ import android.graphics.BitmapFactory;
5
+ import android.graphics.Matrix;
6
+ import android.util.Base64;
7
+ import android.util.Log;
8
+ import androidx.exifinterface.media.ExifInterface;
9
+ import java.io.ByteArrayOutputStream;
10
+ import java.io.File;
11
+ import java.io.FileInputStream;
12
+ import java.io.FileOutputStream;
13
+ import java.io.IOException;
14
+
15
+ final class SagepilotInAppCameraImageProcessor {
16
+ private static final String TAG = "SagepilotInAppCamera";
17
+ private static final int BASE64_BUFFER_SIZE = 8192;
18
+ private static final int MIN_JPEG_QUALITY = 0;
19
+ private static final int MAX_JPEG_QUALITY = 100;
20
+
21
+ private final int maxDimension;
22
+ private final int jpegQuality;
23
+ private final long maxFileSizeBytes;
24
+
25
+ /** Creates a reusable processor for one native camera option set. */
26
+ SagepilotInAppCameraImageProcessor(int maxDimension, int jpegQuality, long maxFileSizeBytes) {
27
+ this.maxDimension = Math.max(1, maxDimension);
28
+ this.jpegQuality = Math.max(MIN_JPEG_QUALITY, Math.min(MAX_JPEG_QUALITY, jpegQuality));
29
+ this.maxFileSizeBytes = Math.max(0L, maxFileSizeBytes);
30
+ logDebug("ImageProcessor created maxDimension=" + this.maxDimension
31
+ + " jpegQuality=" + this.jpegQuality
32
+ + " maxFileSizeBytes=" + this.maxFileSizeBytes);
33
+ }
34
+
35
+ /** Normalizes, compresses, size-checks, and base64-encodes one captured JPEG. */
36
+ ProcessedImage process(File capturedFile, File processedFile) throws IOException, ImageTooLargeException {
37
+ logDebug("ImageProcessor process start capturedBytes=" + capturedFile.length()
38
+ + " capturedPath=" + capturedFile.getAbsolutePath());
39
+ int exifOrientation = readExifOrientation(capturedFile);
40
+ Bitmap decodedBitmap = null;
41
+ Bitmap normalizedBitmap = null;
42
+ Bitmap scaledBitmap = null;
43
+
44
+ try {
45
+ decodedBitmap = decodeSampledBitmap(capturedFile);
46
+ logBitmap("decoded", decodedBitmap);
47
+ normalizedBitmap = applyExifOrientation(decodedBitmap, exifOrientation);
48
+ if (normalizedBitmap != decodedBitmap) {
49
+ logBitmap("orientation-normalized", normalizedBitmap);
50
+ recycleBitmap(decodedBitmap);
51
+ decodedBitmap = null;
52
+ }
53
+
54
+ scaledBitmap = scaleToMaxDimension(normalizedBitmap);
55
+ if (scaledBitmap != normalizedBitmap) {
56
+ logBitmap("scaled", scaledBitmap);
57
+ recycleBitmap(normalizedBitmap);
58
+ normalizedBitmap = null;
59
+ }
60
+
61
+ writeJpeg(scaledBitmap, processedFile);
62
+ long sizeBytes = processedFile.length();
63
+ logDebug("ImageProcessor compressed outputBytes=" + sizeBytes
64
+ + " outputPath=" + processedFile.getAbsolutePath());
65
+ enforceMaxFileSize(sizeBytes);
66
+ String dataBase64 = readFileAsBase64(processedFile);
67
+ logDebug("ImageProcessor process success outputBytes=" + sizeBytes
68
+ + " base64Length=" + dataBase64.length());
69
+ return new ProcessedImage(sizeBytes, dataBase64);
70
+ } finally {
71
+ recycleBitmap(scaledBitmap);
72
+ recycleBitmap(normalizedBitmap);
73
+ recycleBitmap(decodedBitmap);
74
+ logDebug("ImageProcessor process cleanup complete");
75
+ }
76
+ }
77
+
78
+ /** Reads CameraX-written EXIF orientation metadata from the saved JPEG. */
79
+ private int readExifOrientation(File file) throws IOException {
80
+ ExifInterface exifInterface = new ExifInterface(file.getAbsolutePath());
81
+ int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
82
+ logDebug("ImageProcessor EXIF orientation=" + orientation);
83
+ return orientation;
84
+ }
85
+
86
+ /** Decodes only image bounds so the full bitmap can be sampled before allocation. */
87
+ private BitmapFactory.Options decodeBounds(File file) throws IOException {
88
+ BitmapFactory.Options boundsOptions = new BitmapFactory.Options();
89
+ boundsOptions.inJustDecodeBounds = true;
90
+ BitmapFactory.decodeFile(file.getAbsolutePath(), boundsOptions);
91
+
92
+ if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
93
+ throw new IOException("Could not read image bounds.");
94
+ }
95
+
96
+ logDebug("ImageProcessor bounds width=" + boundsOptions.outWidth
97
+ + " height=" + boundsOptions.outHeight
98
+ + " mime=" + boundsOptions.outMimeType);
99
+ return boundsOptions;
100
+ }
101
+
102
+ /** Decodes the captured JPEG with a power-of-two sample size near the target edge. */
103
+ private Bitmap decodeSampledBitmap(File file) throws IOException {
104
+ BitmapFactory.Options boundsOptions = decodeBounds(file);
105
+ BitmapFactory.Options decodeOptions = new BitmapFactory.Options();
106
+ decodeOptions.inSampleSize = calculateSampleSize(boundsOptions.outWidth, boundsOptions.outHeight);
107
+ decodeOptions.inPreferredConfig = Bitmap.Config.ARGB_8888;
108
+ logDebug("ImageProcessor decode sampleSize=" + decodeOptions.inSampleSize);
109
+
110
+ Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOptions);
111
+ if (bitmap == null) {
112
+ throw new IOException("Could not decode image.");
113
+ }
114
+ return bitmap;
115
+ }
116
+
117
+ /** Calculates a sample size that avoids full-resolution allocation for large sensors. */
118
+ private int calculateSampleSize(int width, int height) {
119
+ int longestEdge = Math.max(width, height);
120
+ int sampleSize = 1;
121
+ while ((longestEdge / (sampleSize * 2)) >= maxDimension) {
122
+ sampleSize *= 2;
123
+ }
124
+ int calculatedSampleSize = Math.max(1, sampleSize);
125
+ logDebug("ImageProcessor calculated sampleSize=" + calculatedSampleSize
126
+ + " sourceWidth=" + width
127
+ + " sourceHeight=" + height
128
+ + " maxDimension=" + maxDimension);
129
+ return calculatedSampleSize;
130
+ }
131
+
132
+ /** Applies EXIF rotation and mirroring so the output pixels are orientation-normalized. */
133
+ private Bitmap applyExifOrientation(Bitmap bitmap, int exifOrientation) {
134
+ Matrix matrix = createExifMatrix(exifOrientation);
135
+ if (matrix.isIdentity()) {
136
+ logDebug("ImageProcessor EXIF transform not needed");
137
+ return bitmap;
138
+ }
139
+ logDebug("ImageProcessor applying EXIF transform orientation=" + exifOrientation);
140
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
141
+ }
142
+
143
+ /** Builds a pixel transform for all EXIF orientation and mirror variants. */
144
+ private Matrix createExifMatrix(int exifOrientation) {
145
+ Matrix matrix = new Matrix();
146
+ switch (exifOrientation) {
147
+ case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
148
+ matrix.setScale(-1.0f, 1.0f);
149
+ break;
150
+ case ExifInterface.ORIENTATION_ROTATE_180:
151
+ matrix.setRotate(180.0f);
152
+ break;
153
+ case ExifInterface.ORIENTATION_FLIP_VERTICAL:
154
+ matrix.setRotate(180.0f);
155
+ matrix.postScale(-1.0f, 1.0f);
156
+ break;
157
+ case ExifInterface.ORIENTATION_TRANSPOSE:
158
+ matrix.setRotate(90.0f);
159
+ matrix.postScale(-1.0f, 1.0f);
160
+ break;
161
+ case ExifInterface.ORIENTATION_ROTATE_90:
162
+ matrix.setRotate(90.0f);
163
+ break;
164
+ case ExifInterface.ORIENTATION_TRANSVERSE:
165
+ matrix.setRotate(-90.0f);
166
+ matrix.postScale(-1.0f, 1.0f);
167
+ break;
168
+ case ExifInterface.ORIENTATION_ROTATE_270:
169
+ matrix.setRotate(-90.0f);
170
+ break;
171
+ default:
172
+ break;
173
+ }
174
+ return matrix;
175
+ }
176
+
177
+ /** Downscales the normalized bitmap when its longest edge exceeds the configured cap. */
178
+ private Bitmap scaleToMaxDimension(Bitmap bitmap) {
179
+ int width = bitmap.getWidth();
180
+ int height = bitmap.getHeight();
181
+ int longestEdge = Math.max(width, height);
182
+
183
+ if (longestEdge <= maxDimension) {
184
+ logDebug("ImageProcessor scaling not needed width=" + width + " height=" + height);
185
+ return bitmap;
186
+ }
187
+
188
+ float scale = (float) maxDimension / (float) longestEdge;
189
+ int targetWidth = Math.max(1, Math.round(width * scale));
190
+ int targetHeight = Math.max(1, Math.round(height * scale));
191
+ logDebug("ImageProcessor scaling width=" + width
192
+ + " height=" + height
193
+ + " targetWidth=" + targetWidth
194
+ + " targetHeight=" + targetHeight);
195
+ return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true);
196
+ }
197
+
198
+ /** Compresses the final normalized bitmap into the processed JPEG file. */
199
+ private void writeJpeg(Bitmap bitmap, File outputFile) throws IOException {
200
+ logDebug("ImageProcessor compressing JPEG quality=" + jpegQuality);
201
+ try (FileOutputStream outputStream = new FileOutputStream(outputFile)) {
202
+ if (!bitmap.compress(Bitmap.CompressFormat.JPEG, jpegQuality, outputStream)) {
203
+ throw new IOException("Could not compress JPEG.");
204
+ }
205
+ outputStream.flush();
206
+ }
207
+ }
208
+
209
+ /** Rejects compressed output that exceeds the bridge image size cap. */
210
+ private void enforceMaxFileSize(long sizeBytes) throws ImageTooLargeException {
211
+ if (maxFileSizeBytes > 0L && sizeBytes > maxFileSizeBytes) {
212
+ logWarn("ImageProcessor output too large sizeBytes=" + sizeBytes
213
+ + " maxFileSizeBytes=" + maxFileSizeBytes);
214
+ throw new ImageTooLargeException(sizeBytes, maxFileSizeBytes);
215
+ }
216
+ }
217
+
218
+ /** Reads a bounded processed JPEG into a base64 payload without line wrapping. */
219
+ private String readFileAsBase64(File file) throws IOException {
220
+ logDebug("ImageProcessor reading base64 bytes=" + file.length());
221
+ try (FileInputStream inputStream = new FileInputStream(file);
222
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream((int) Math.min(file.length(), 1024L * 1024L))) {
223
+ byte[] buffer = new byte[BASE64_BUFFER_SIZE];
224
+ int bytesRead;
225
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
226
+ outputStream.write(buffer, 0, bytesRead);
227
+ }
228
+ return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP);
229
+ }
230
+ }
231
+
232
+ /** Recycles a bitmap when it is no longer needed by the processor. */
233
+ private void recycleBitmap(Bitmap bitmap) {
234
+ if (bitmap != null && !bitmap.isRecycled()) {
235
+ logDebug("ImageProcessor recycling bitmap width=" + bitmap.getWidth() + " height=" + bitmap.getHeight());
236
+ bitmap.recycle();
237
+ }
238
+ }
239
+
240
+ /** Logs decoded or transformed bitmap dimensions without reading pixel data. */
241
+ private void logBitmap(String label, Bitmap bitmap) {
242
+ logDebug("ImageProcessor " + label + " bitmap width=" + bitmap.getWidth()
243
+ + " height=" + bitmap.getHeight()
244
+ + " bytes=" + bitmap.getByteCount());
245
+ }
246
+
247
+ /** Writes a native debug log and mirrors it into the React Native console. */
248
+ private void logDebug(String message) {
249
+ Log.d(TAG, message);
250
+ SagepilotInAppCameraDebugEvents.debug(message);
251
+ }
252
+
253
+ /** Writes a native warning log and mirrors it into the React Native console. */
254
+ private void logWarn(String message) {
255
+ Log.w(TAG, message);
256
+ SagepilotInAppCameraDebugEvents.warn(message);
257
+ }
258
+
259
+ static final class ProcessedImage {
260
+ final long sizeBytes;
261
+ final String dataBase64;
262
+
263
+ /** Stores the bridge-ready processed image payload. */
264
+ ProcessedImage(long sizeBytes, String dataBase64) {
265
+ this.sizeBytes = sizeBytes;
266
+ this.dataBase64 = dataBase64;
267
+ }
268
+ }
269
+
270
+ static final class ImageTooLargeException extends Exception {
271
+ private static final long serialVersionUID = 1L;
272
+
273
+ /** Creates a size-cap failure with actual and allowed byte counts. */
274
+ ImageTooLargeException(long actualSizeBytes, long maxSizeBytes) {
275
+ super("Processed image is too large: " + actualSizeBytes + " > " + maxSizeBytes);
276
+ }
277
+ }
278
+ }