@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.
- package/LICENSE.md +21 -0
- package/README.md +75 -0
- package/android/build.gradle +45 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraActivity.java +1549 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraDebugEvents.java +63 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraImageProcessor.java +278 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraModule.java +1012 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraOverlay.java +1419 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraResultStore.java +135 -0
- package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotReactNativeCameraAddonPackage.java +22 -0
- package/android/src/main/res/drawable/sagepilot_ic_check.xml +12 -0
- package/android/src/main/res/drawable/sagepilot_ic_rotate_ccw.xml +18 -0
- package/android/src/main/res/drawable/sagepilot_ic_switch_camera.xml +36 -0
- package/android/src/main/res/drawable/sagepilot_ic_x.xml +18 -0
- package/android/src/main/res/values/styles.xml +7 -0
- package/dist/index.d.mts +67 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +339 -0
- package/dist/index.mjs +320 -0
- package/package.json +48 -0
- package/react-native.config.js +12 -0
package/android/src/main/java/ai/sagepilot/reactnativecameraaddon/SagepilotInAppCameraModule.java
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
package ai.sagepilot.reactnativecameraaddon;
|
|
2
|
+
|
|
3
|
+
import android.Manifest;
|
|
4
|
+
import android.app.Activity;
|
|
5
|
+
import android.content.ActivityNotFoundException;
|
|
6
|
+
import android.content.ClipData;
|
|
7
|
+
import android.content.ContentResolver;
|
|
8
|
+
import android.content.Intent;
|
|
9
|
+
import android.content.pm.PackageManager;
|
|
10
|
+
import android.database.Cursor;
|
|
11
|
+
import android.net.Uri;
|
|
12
|
+
import android.provider.OpenableColumns;
|
|
13
|
+
import android.util.Base64;
|
|
14
|
+
import android.util.Log;
|
|
15
|
+
import androidx.activity.result.PickVisualMediaRequest;
|
|
16
|
+
import androidx.activity.result.contract.ActivityResultContracts;
|
|
17
|
+
import androidx.core.content.ContextCompat;
|
|
18
|
+
import androidx.lifecycle.LifecycleOwner;
|
|
19
|
+
import com.facebook.react.bridge.Arguments;
|
|
20
|
+
import com.facebook.react.bridge.BaseActivityEventListener;
|
|
21
|
+
import com.facebook.react.bridge.LifecycleEventListener;
|
|
22
|
+
import com.facebook.react.bridge.Promise;
|
|
23
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
24
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
25
|
+
import com.facebook.react.bridge.ReactMethod;
|
|
26
|
+
import com.facebook.react.bridge.ReadableMap;
|
|
27
|
+
import com.facebook.react.bridge.WritableArray;
|
|
28
|
+
import com.facebook.react.bridge.WritableMap;
|
|
29
|
+
import java.io.ByteArrayOutputStream;
|
|
30
|
+
import java.io.IOException;
|
|
31
|
+
import java.io.InputStream;
|
|
32
|
+
import java.util.ArrayList;
|
|
33
|
+
import java.util.HashMap;
|
|
34
|
+
import java.util.List;
|
|
35
|
+
import java.util.Map;
|
|
36
|
+
|
|
37
|
+
public class SagepilotInAppCameraModule extends ReactContextBaseJavaModule implements LifecycleEventListener {
|
|
38
|
+
public static final String NAME = "SagepilotInAppCamera";
|
|
39
|
+
public static final String CAMERA_ACTIVITY_CLASS_NAME = "ai.sagepilot.reactnativecameraaddon.SagepilotInAppCameraActivity";
|
|
40
|
+
private static final String TAG = "SagepilotInAppCamera";
|
|
41
|
+
|
|
42
|
+
public static final int REQUEST_CODE_OPEN_CAMERA = 0x5347;
|
|
43
|
+
public static final int REQUEST_CODE_OPEN_IMAGE_LIBRARY = 0x5348;
|
|
44
|
+
public static final int REQUEST_CODE_OPEN_DOCUMENTS = 0x5349;
|
|
45
|
+
public static final int RESULT_ERROR = Activity.RESULT_FIRST_USER + 0x5347;
|
|
46
|
+
public static final int MODULE_CAPABILITIES_VERSION = 2;
|
|
47
|
+
|
|
48
|
+
public static final String EXTRA_IMAGE_MAX_DIMENSION =
|
|
49
|
+
"ai.sagepilot.reactnativecameraaddon.camera.IMAGE_MAX_DIMENSION";
|
|
50
|
+
public static final String EXTRA_IMAGE_QUALITY =
|
|
51
|
+
"ai.sagepilot.reactnativecameraaddon.camera.IMAGE_QUALITY";
|
|
52
|
+
public static final String EXTRA_IMAGE_MAX_FILE_SIZE_BYTES =
|
|
53
|
+
"ai.sagepilot.reactnativecameraaddon.camera.IMAGE_MAX_FILE_SIZE_BYTES";
|
|
54
|
+
|
|
55
|
+
public static final String EXTRA_RESULT_FILE_NAME =
|
|
56
|
+
"ai.sagepilot.reactnativecameraaddon.camera.RESULT_FILE_NAME";
|
|
57
|
+
public static final String EXTRA_RESULT_MIME_TYPE =
|
|
58
|
+
"ai.sagepilot.reactnativecameraaddon.camera.RESULT_MIME_TYPE";
|
|
59
|
+
public static final String EXTRA_RESULT_SIZE =
|
|
60
|
+
"ai.sagepilot.reactnativecameraaddon.camera.RESULT_SIZE";
|
|
61
|
+
public static final String EXTRA_RESULT_DATA_BASE64 =
|
|
62
|
+
"ai.sagepilot.reactnativecameraaddon.camera.RESULT_DATA_BASE64";
|
|
63
|
+
public static final String EXTRA_RESULT_ID =
|
|
64
|
+
"ai.sagepilot.reactnativecameraaddon.camera.RESULT_ID";
|
|
65
|
+
|
|
66
|
+
public static final String EXTRA_ERROR_CODE =
|
|
67
|
+
"ai.sagepilot.reactnativecameraaddon.camera.ERROR_CODE";
|
|
68
|
+
public static final String EXTRA_ERROR_MESSAGE =
|
|
69
|
+
"ai.sagepilot.reactnativecameraaddon.camera.ERROR_MESSAGE";
|
|
70
|
+
|
|
71
|
+
public static final String ERROR_PERMISSION_DENIED = "permission_denied";
|
|
72
|
+
public static final String ERROR_CAMERA_UNAVAILABLE = "camera_unavailable";
|
|
73
|
+
public static final String ERROR_ENCODE_FAILED = "encode_failed";
|
|
74
|
+
public static final String ERROR_FILE_TOO_LARGE = "file_too_large";
|
|
75
|
+
public static final String ERROR_READ_FAILED = "read_failed";
|
|
76
|
+
|
|
77
|
+
private static final int BASE64_BUFFER_SIZE = 8192;
|
|
78
|
+
private static final int DEFAULT_IMAGE_MAX_DIMENSION = 1920;
|
|
79
|
+
private static final double DEFAULT_IMAGE_QUALITY = 0.8;
|
|
80
|
+
private static final long DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES = 15L * 1024L * 1024L;
|
|
81
|
+
private static final long DEFAULT_DOCUMENT_MAX_FILE_SIZE_BYTES = 20L * 1024L * 1024L;
|
|
82
|
+
private static final String DEFAULT_IMAGE_MIME_TYPE = "image/jpeg";
|
|
83
|
+
private static final String DEFAULT_DOCUMENT_MIME_TYPE = "application/octet-stream";
|
|
84
|
+
private static final int PHOTO_PICKER_MAX_ITEMS = 20;
|
|
85
|
+
private static final String[] DOCUMENT_MIME_TYPES = new String[] { "*/*" };
|
|
86
|
+
|
|
87
|
+
private final ReactApplicationContext reactContext;
|
|
88
|
+
private final Object pendingLock = new Object();
|
|
89
|
+
private final BaseActivityEventListener activityEventListener = new BaseActivityEventListener() {
|
|
90
|
+
/** Handles the SDK camera Activity result for the pending JS promise. */
|
|
91
|
+
@Override
|
|
92
|
+
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
|
|
93
|
+
handleActivityResult(requestCode, resultCode, data);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
private Promise pendingPromise;
|
|
98
|
+
private long pendingMaxFileSizeBytes;
|
|
99
|
+
private boolean pendingCameraCapture;
|
|
100
|
+
private final List<Promise> pendingCameraPromises = new ArrayList<>();
|
|
101
|
+
private boolean invalidated;
|
|
102
|
+
private SagepilotInAppCameraOverlay activeCameraOverlay;
|
|
103
|
+
|
|
104
|
+
/** Creates the in-app camera native module and attaches lifecycle listeners. */
|
|
105
|
+
public SagepilotInAppCameraModule(ReactApplicationContext reactContext) {
|
|
106
|
+
super(reactContext);
|
|
107
|
+
this.reactContext = reactContext;
|
|
108
|
+
reactContext.addActivityEventListener(activityEventListener);
|
|
109
|
+
reactContext.addLifecycleEventListener(this);
|
|
110
|
+
SagepilotInAppCameraDebugEvents.register(reactContext);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Returns the NativeModules registration name consumed by the JS wrapper. */
|
|
114
|
+
@Override
|
|
115
|
+
public String getName() {
|
|
116
|
+
return NAME;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reports native picker capabilities so JavaScript can detect stale APKs. */
|
|
120
|
+
@Override
|
|
121
|
+
public Map<String, Object> getConstants() {
|
|
122
|
+
Map<String, Object> constants = new HashMap<>();
|
|
123
|
+
constants.put("moduleCapabilitiesVersion", MODULE_CAPABILITIES_VERSION);
|
|
124
|
+
constants.put("supportsInAppCamera", true);
|
|
125
|
+
constants.put("supportsImageLibrary", true);
|
|
126
|
+
constants.put("supportsDocumentPicker", true);
|
|
127
|
+
return constants;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Opens the SDK-owned camera Activity and resolves one picked file or null. */
|
|
131
|
+
@ReactMethod
|
|
132
|
+
public void openCamera(ReadableMap options, Promise promise) {
|
|
133
|
+
logDebug("openCamera requested");
|
|
134
|
+
if (invalidated) {
|
|
135
|
+
logWarn("openCamera rejected: module invalidated");
|
|
136
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "The camera module is no longer available.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (hasPendingPromise()) {
|
|
141
|
+
if (addPendingCameraPromise(promise)) {
|
|
142
|
+
logWarn("openCamera coalesced into pending capture");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
logWarn("openCamera rejected: pending capture exists");
|
|
146
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "Another camera capture is already in progress.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Activity activity = reactContext.getCurrentActivity();
|
|
151
|
+
if (activity == null) {
|
|
152
|
+
logWarn("openCamera rejected: no current Activity");
|
|
153
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "No active host activity is available to open the camera.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!hasCameraPermission()) {
|
|
158
|
+
logWarn("openCamera continuing without permission; camera surface will request CAMERA");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasCameraHardware()) {
|
|
162
|
+
logWarn("openCamera rejected: no camera hardware");
|
|
163
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "This device does not have an available camera.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (canUseCameraOverlay(activity)) {
|
|
168
|
+
openCameraOverlay(activity, options, promise);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!hasCameraActivityClass()) {
|
|
173
|
+
logWarn("openCamera rejected: Activity class missing");
|
|
174
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "The in-app camera screen is not available in this build.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
startCameraActivity(activity, options, promise);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Starts the legacy Activity camera path used for permission fallback or unsupported host lifecycles. */
|
|
182
|
+
private void startCameraActivity(Activity activity, ReadableMap options, Promise promise) {
|
|
183
|
+
Intent intent = createCameraIntent(options);
|
|
184
|
+
setPendingCameraPromise(promise);
|
|
185
|
+
|
|
186
|
+
activity.runOnUiThread(() -> {
|
|
187
|
+
if (invalidated || !hasPendingPromise()) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
logDebug("starting CameraX Activity");
|
|
192
|
+
activity.startActivityForResult(intent, REQUEST_CODE_OPEN_CAMERA);
|
|
193
|
+
} catch (ActivityNotFoundException error) {
|
|
194
|
+
logError("CameraX Activity not registered", error);
|
|
195
|
+
rejectPendingPromise(ERROR_CAMERA_UNAVAILABLE, "The in-app camera screen is not registered.", error);
|
|
196
|
+
} catch (RuntimeException error) {
|
|
197
|
+
logError("Could not start CameraX Activity", error);
|
|
198
|
+
rejectPendingPromise(ERROR_CAMERA_UNAVAILABLE, "Could not open the in-app camera.", error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Checks whether the host Activity can keep the camera in-process above the WebView. */
|
|
204
|
+
private boolean canUseCameraOverlay(Activity activity) {
|
|
205
|
+
return hasCameraPermission() && activity instanceof LifecycleOwner;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Opens CameraX as an in-process host-Activity overlay. */
|
|
209
|
+
private void openCameraOverlay(Activity activity, ReadableMap options, Promise promise) {
|
|
210
|
+
int imageMaxDimension = readIntOption(options, "imageMaxDimension", DEFAULT_IMAGE_MAX_DIMENSION);
|
|
211
|
+
int imageQuality = qualityRatioToPercent(readDoubleOption(options, "imageQuality", DEFAULT_IMAGE_QUALITY));
|
|
212
|
+
long imageMaxFileSizeBytes = readLongOption(
|
|
213
|
+
options,
|
|
214
|
+
"imageMaxFileSizeBytes",
|
|
215
|
+
DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES
|
|
216
|
+
);
|
|
217
|
+
setPendingCameraPromise(promise);
|
|
218
|
+
|
|
219
|
+
activity.runOnUiThread(() -> {
|
|
220
|
+
if (invalidated || !hasPendingPromise()) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
activeCameraOverlay = new SagepilotInAppCameraOverlay(
|
|
226
|
+
activity,
|
|
227
|
+
imageMaxDimension,
|
|
228
|
+
imageQuality,
|
|
229
|
+
imageMaxFileSizeBytes,
|
|
230
|
+
new SagepilotInAppCameraOverlay.Listener() {
|
|
231
|
+
/** Resolves the JS promise with a processed overlay capture. */
|
|
232
|
+
@Override
|
|
233
|
+
public void onSuccess(String fileName, String mimeType, long sizeBytes, String dataBase64) {
|
|
234
|
+
handleOverlaySuccess(fileName, mimeType, sizeBytes, dataBase64);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Resolves the JS promise with cancellation from the overlay. */
|
|
238
|
+
@Override
|
|
239
|
+
public void onCancel() {
|
|
240
|
+
handleOverlayCancel();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Rejects the JS promise with a stable overlay error. */
|
|
244
|
+
@Override
|
|
245
|
+
public void onError(String code, String message, Throwable error) {
|
|
246
|
+
handleOverlayError(code, message, error);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
logDebug("showing in-process CameraX overlay");
|
|
251
|
+
activeCameraOverlay.show();
|
|
252
|
+
} catch (RuntimeException error) {
|
|
253
|
+
logError("Could not show in-process CameraX overlay", error);
|
|
254
|
+
destroyActiveCameraOverlay();
|
|
255
|
+
rejectPendingPromise(ERROR_CAMERA_UNAVAILABLE, "Could not open the in-app camera.", error);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Resolves the pending promise with an in-process overlay capture. */
|
|
261
|
+
private void handleOverlaySuccess(String fileName, String mimeType, long sizeBytes, String dataBase64) {
|
|
262
|
+
activeCameraOverlay = null;
|
|
263
|
+
List<Promise> promises = takePendingCameraPromises();
|
|
264
|
+
if (promises.isEmpty()) {
|
|
265
|
+
logWarn("overlay success ignored: no pending promise");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (Promise promise : promises) {
|
|
269
|
+
resolvePickedFile(promise, fileName, mimeType, sizeBytes, dataBase64);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Resolves the pending promise as a customer cancellation from the overlay. */
|
|
274
|
+
private void handleOverlayCancel() {
|
|
275
|
+
activeCameraOverlay = null;
|
|
276
|
+
List<Promise> promises = takePendingCameraPromises();
|
|
277
|
+
if (promises.isEmpty()) {
|
|
278
|
+
logWarn("overlay cancel ignored: no pending promise");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
for (Promise promise : promises) {
|
|
282
|
+
promise.resolve(null);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Rejects the pending promise with an overlay failure. */
|
|
287
|
+
private void handleOverlayError(String code, String message, Throwable error) {
|
|
288
|
+
activeCameraOverlay = null;
|
|
289
|
+
List<Promise> promises = takePendingCameraPromises();
|
|
290
|
+
if (promises.isEmpty()) {
|
|
291
|
+
logWarn("overlay error ignored: no pending promise");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
for (Promise promise : promises) {
|
|
295
|
+
rejectPromise(promise, code, message, error);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Destroys the active in-process overlay without settling the promise. */
|
|
300
|
+
private void destroyActiveCameraOverlay() {
|
|
301
|
+
SagepilotInAppCameraOverlay overlay = activeCameraOverlay;
|
|
302
|
+
activeCameraOverlay = null;
|
|
303
|
+
if (overlay != null && overlay.isActive()) {
|
|
304
|
+
overlay.destroySilentlyOnUiThread();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Opens the Android system image picker and resolves selected image files. */
|
|
309
|
+
@ReactMethod
|
|
310
|
+
public void openImageLibrary(ReadableMap options, Promise promise) {
|
|
311
|
+
openExternalPicker(
|
|
312
|
+
options,
|
|
313
|
+
promise,
|
|
314
|
+
REQUEST_CODE_OPEN_IMAGE_LIBRARY,
|
|
315
|
+
"image/*",
|
|
316
|
+
"image library",
|
|
317
|
+
"imageMaxFileSizeBytes",
|
|
318
|
+
DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Opens the Android system document picker and resolves selected document files. */
|
|
323
|
+
@ReactMethod
|
|
324
|
+
public void openDocumentPicker(ReadableMap options, Promise promise) {
|
|
325
|
+
openExternalPicker(
|
|
326
|
+
options,
|
|
327
|
+
promise,
|
|
328
|
+
REQUEST_CODE_OPEN_DOCUMENTS,
|
|
329
|
+
"*/*",
|
|
330
|
+
"document picker",
|
|
331
|
+
"documentMaxFileSizeBytes",
|
|
332
|
+
DEFAULT_DOCUMENT_MAX_FILE_SIZE_BYTES
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Accepts JavaScript debug-event subscriptions required by NativeEventEmitter. */
|
|
337
|
+
@ReactMethod
|
|
338
|
+
public void addListener(String eventName) {
|
|
339
|
+
// Events are emitted through RCTDeviceEventEmitter; no per-event native setup is needed.
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Accepts JavaScript debug-event unsubscriptions required by NativeEventEmitter. */
|
|
343
|
+
@ReactMethod
|
|
344
|
+
public void removeListeners(double count) {
|
|
345
|
+
// Events are emitted through RCTDeviceEventEmitter; no per-event native teardown is needed.
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Keeps the pending capture alive while the SDK camera Activity is foregrounded. */
|
|
349
|
+
@Override
|
|
350
|
+
public void onHostPause() {
|
|
351
|
+
// Opening the SDK camera Activity pauses the React host Activity; this is not a cancellation.
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Leaves pending capture ownership unchanged when the React host resumes. */
|
|
355
|
+
@Override
|
|
356
|
+
public void onHostResume() {
|
|
357
|
+
// Activity result delivery is the source of truth for capture completion.
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Rejects a pending capture if the React host Activity is destroyed mid-capture. */
|
|
361
|
+
@Override
|
|
362
|
+
public void onHostDestroy() {
|
|
363
|
+
destroyActiveCameraOverlay();
|
|
364
|
+
rejectPendingPromise(
|
|
365
|
+
ERROR_CAMERA_UNAVAILABLE,
|
|
366
|
+
"Camera capture was cancelled because the host activity was destroyed.",
|
|
367
|
+
null
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Removes listeners and rejects any pending capture when React invalidates the module. */
|
|
372
|
+
@Override
|
|
373
|
+
public void invalidate() {
|
|
374
|
+
invalidated = true;
|
|
375
|
+
destroyActiveCameraOverlay();
|
|
376
|
+
reactContext.removeActivityEventListener(activityEventListener);
|
|
377
|
+
reactContext.removeLifecycleEventListener(this);
|
|
378
|
+
SagepilotInAppCameraDebugEvents.unregister(reactContext);
|
|
379
|
+
rejectPendingPromise(
|
|
380
|
+
ERROR_CAMERA_UNAVAILABLE,
|
|
381
|
+
"Camera capture was cancelled because the React Native instance was invalidated.",
|
|
382
|
+
null
|
|
383
|
+
);
|
|
384
|
+
super.invalidate();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Builds the explicit Activity intent with normalized camera processing options. */
|
|
388
|
+
private Intent createCameraIntent(ReadableMap options) {
|
|
389
|
+
Intent intent = new Intent();
|
|
390
|
+
intent.setClassName(reactContext.getPackageName(), CAMERA_ACTIVITY_CLASS_NAME);
|
|
391
|
+
intent.putExtra(
|
|
392
|
+
EXTRA_IMAGE_MAX_DIMENSION,
|
|
393
|
+
readIntOption(options, "imageMaxDimension", DEFAULT_IMAGE_MAX_DIMENSION)
|
|
394
|
+
);
|
|
395
|
+
intent.putExtra(
|
|
396
|
+
EXTRA_IMAGE_QUALITY,
|
|
397
|
+
readDoubleOption(options, "imageQuality", DEFAULT_IMAGE_QUALITY)
|
|
398
|
+
);
|
|
399
|
+
intent.putExtra(
|
|
400
|
+
EXTRA_IMAGE_MAX_FILE_SIZE_BYTES,
|
|
401
|
+
readLongOption(options, "imageMaxFileSizeBytes", DEFAULT_IMAGE_MAX_FILE_SIZE_BYTES)
|
|
402
|
+
);
|
|
403
|
+
return intent;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Resolves or rejects the pending JS promise from the camera Activity result. */
|
|
407
|
+
private void handleActivityResult(int requestCode, int resultCode, Intent data) {
|
|
408
|
+
if (
|
|
409
|
+
requestCode != REQUEST_CODE_OPEN_CAMERA &&
|
|
410
|
+
requestCode != REQUEST_CODE_OPEN_IMAGE_LIBRARY &&
|
|
411
|
+
requestCode != REQUEST_CODE_OPEN_DOCUMENTS
|
|
412
|
+
) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (requestCode == REQUEST_CODE_OPEN_IMAGE_LIBRARY || requestCode == REQUEST_CODE_OPEN_DOCUMENTS) {
|
|
417
|
+
handleExternalPickerResult(requestCode, resultCode, data);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
logDebug("camera Activity result received: resultCode=" + resultCode);
|
|
422
|
+
List<Promise> promises = takePendingCameraPromises();
|
|
423
|
+
if (promises.isEmpty()) {
|
|
424
|
+
logWarn("camera Activity result ignored: no pending promise");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (resultCode == Activity.RESULT_OK) {
|
|
429
|
+
resolvePickedFiles(promises, data);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (resultCode == Activity.RESULT_CANCELED) {
|
|
434
|
+
logDebug("camera Activity cancelled");
|
|
435
|
+
for (Promise promise : promises) {
|
|
436
|
+
promise.resolve(null);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (resultCode == RESULT_ERROR) {
|
|
442
|
+
String code = readStringExtra(data, EXTRA_ERROR_CODE, ERROR_CAMERA_UNAVAILABLE);
|
|
443
|
+
String message = readStringExtra(data, EXTRA_ERROR_MESSAGE, "Could not open the in-app camera.");
|
|
444
|
+
for (Promise promise : promises) {
|
|
445
|
+
rejectPromise(promise, code, message);
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (Promise promise : promises) {
|
|
451
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "The in-app camera closed unexpectedly.");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Opens an Android system content picker and tracks the pending JS promise. */
|
|
456
|
+
private void openExternalPicker(
|
|
457
|
+
ReadableMap options,
|
|
458
|
+
Promise promise,
|
|
459
|
+
int requestCode,
|
|
460
|
+
String mimeType,
|
|
461
|
+
String pickerName,
|
|
462
|
+
String maxFileSizeOption,
|
|
463
|
+
long defaultMaxFileSizeBytes
|
|
464
|
+
) {
|
|
465
|
+
logDebug("open " + pickerName + " requested");
|
|
466
|
+
if (invalidated) {
|
|
467
|
+
logWarn("open " + pickerName + " rejected: module invalidated");
|
|
468
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "The file picker module is no longer available.");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (hasPendingPromise()) {
|
|
473
|
+
logWarn("open " + pickerName + " rejected: pending picker exists");
|
|
474
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "Another file picker is already in progress.");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
Activity activity = reactContext.getCurrentActivity();
|
|
479
|
+
if (activity == null) {
|
|
480
|
+
logWarn("open " + pickerName + " rejected: no current Activity");
|
|
481
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "No active host activity is available to open the file picker.");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
boolean multiple = readBooleanOption(options, "multiple", true);
|
|
486
|
+
long maxFileSizeBytes = readLongOption(options, maxFileSizeOption, defaultMaxFileSizeBytes);
|
|
487
|
+
Intent intent = createExternalPickerIntent(requestCode, mimeType, multiple);
|
|
488
|
+
setPendingPromise(promise, maxFileSizeBytes);
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
logDebug("starting " + pickerName + ": multiple=" + multiple + ", maxFileSizeBytes=" + maxFileSizeBytes);
|
|
492
|
+
activity.startActivityForResult(intent, requestCode);
|
|
493
|
+
} catch (ActivityNotFoundException error) {
|
|
494
|
+
logError("No Activity found for " + pickerName, error);
|
|
495
|
+
rejectPendingPromise(ERROR_CAMERA_UNAVAILABLE, "No file picker is available on this device.", error);
|
|
496
|
+
} catch (RuntimeException error) {
|
|
497
|
+
logError("Could not start " + pickerName, error);
|
|
498
|
+
rejectPendingPromise(ERROR_CAMERA_UNAVAILABLE, "Could not open the file picker.", error);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Creates the correct Android picker intent for image library or documents. */
|
|
503
|
+
private Intent createExternalPickerIntent(int requestCode, String mimeType, boolean multiple) {
|
|
504
|
+
if (requestCode == REQUEST_CODE_OPEN_IMAGE_LIBRARY) {
|
|
505
|
+
return createImageLibraryPickerIntent(multiple);
|
|
506
|
+
}
|
|
507
|
+
return createGetContentPickerIntent(mimeType, multiple, DOCUMENT_MIME_TYPES, "Choose file");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Creates an Android Photo Picker intent for media, falling back to GET_CONTENT on older devices. */
|
|
511
|
+
private Intent createImageLibraryPickerIntent(boolean multiple) {
|
|
512
|
+
if (ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(reactContext)) {
|
|
513
|
+
PickVisualMediaRequest request = new PickVisualMediaRequest.Builder()
|
|
514
|
+
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE)
|
|
515
|
+
.setMaxItems(multiple ? PHOTO_PICKER_MAX_ITEMS : 1)
|
|
516
|
+
.build();
|
|
517
|
+
if (multiple) {
|
|
518
|
+
return new ActivityResultContracts.PickMultipleVisualMedia(PHOTO_PICKER_MAX_ITEMS)
|
|
519
|
+
.createIntent(reactContext, request);
|
|
520
|
+
}
|
|
521
|
+
return new ActivityResultContracts.PickVisualMedia().createIntent(reactContext, request);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return createGetContentPickerIntent("image/*", multiple, null, "Choose image");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Creates a broad ACTION_GET_CONTENT chooser for providers such as Files and Drive. */
|
|
528
|
+
private Intent createGetContentPickerIntent(
|
|
529
|
+
String mimeType,
|
|
530
|
+
boolean multiple,
|
|
531
|
+
String[] mimeTypes,
|
|
532
|
+
String title
|
|
533
|
+
) {
|
|
534
|
+
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
|
535
|
+
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
|
536
|
+
intent.setType(mimeType);
|
|
537
|
+
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
|
|
538
|
+
if (mimeTypes != null && mimeTypes.length > 0) {
|
|
539
|
+
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
|
|
540
|
+
}
|
|
541
|
+
return Intent.createChooser(intent, title);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Resolves Android system picker results for gallery and document sources. */
|
|
545
|
+
private void handleExternalPickerResult(int requestCode, int resultCode, Intent data) {
|
|
546
|
+
String pickerName = requestCode == REQUEST_CODE_OPEN_IMAGE_LIBRARY ? "image library" : "document picker";
|
|
547
|
+
logDebug(pickerName + " Activity result received: resultCode=" + resultCode);
|
|
548
|
+
PendingPicker pendingPicker = takePendingPicker();
|
|
549
|
+
Promise promise = pendingPicker.promise;
|
|
550
|
+
if (promise == null) {
|
|
551
|
+
logWarn(pickerName + " Activity result ignored: no pending promise");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (resultCode == Activity.RESULT_CANCELED) {
|
|
556
|
+
logDebug(pickerName + " cancelled");
|
|
557
|
+
promise.resolve(Arguments.createArray());
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (resultCode != Activity.RESULT_OK) {
|
|
562
|
+
rejectPromise(promise, ERROR_CAMERA_UNAVAILABLE, "The file picker closed unexpectedly.");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
WritableArray files = createPickedFilesArray(
|
|
568
|
+
collectResultUris(data),
|
|
569
|
+
requestCode == REQUEST_CODE_OPEN_IMAGE_LIBRARY ? DEFAULT_IMAGE_MIME_TYPE : DEFAULT_DOCUMENT_MIME_TYPE,
|
|
570
|
+
pendingPicker.maxFileSizeBytes
|
|
571
|
+
);
|
|
572
|
+
logDebug(pickerName + " resolved files");
|
|
573
|
+
promise.resolve(files);
|
|
574
|
+
} catch (PickerFileTooLargeException error) {
|
|
575
|
+
logWarn(pickerName + " file too large", error);
|
|
576
|
+
rejectPromise(promise, ERROR_FILE_TOO_LARGE, error.getMessage(), error);
|
|
577
|
+
} catch (IOException | RuntimeException error) {
|
|
578
|
+
logError(pickerName + " read failed", error);
|
|
579
|
+
rejectPromise(promise, ERROR_READ_FAILED, "Could not read the selected file.", error);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Collects all content URIs returned by Android's picker result. */
|
|
584
|
+
private List<Uri> collectResultUris(Intent data) {
|
|
585
|
+
List<Uri> uris = new ArrayList<>();
|
|
586
|
+
if (data == null) {
|
|
587
|
+
return uris;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
ClipData clipData = data.getClipData();
|
|
591
|
+
if (clipData != null) {
|
|
592
|
+
for (int index = 0; index < clipData.getItemCount(); index += 1) {
|
|
593
|
+
Uri uri = clipData.getItemAt(index).getUri();
|
|
594
|
+
if (uri != null) {
|
|
595
|
+
uris.add(uri);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
Uri uri = data.getData();
|
|
601
|
+
if (uri != null && !uris.contains(uri)) {
|
|
602
|
+
uris.add(uri);
|
|
603
|
+
}
|
|
604
|
+
return uris;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Converts selected content URIs into bridge-ready picked-file payloads. */
|
|
608
|
+
private WritableArray createPickedFilesArray(
|
|
609
|
+
List<Uri> uris,
|
|
610
|
+
String fallbackMimeType,
|
|
611
|
+
long maxFileSizeBytes
|
|
612
|
+
) throws IOException, PickerFileTooLargeException {
|
|
613
|
+
WritableArray files = Arguments.createArray();
|
|
614
|
+
for (int index = 0; index < uris.size(); index += 1) {
|
|
615
|
+
Uri uri = uris.get(index);
|
|
616
|
+
PickedContentMetadata metadata = readPickedContentMetadata(uri, fallbackMimeType, index);
|
|
617
|
+
if (maxFileSizeBytes > 0L && metadata.sizeBytes > maxFileSizeBytes) {
|
|
618
|
+
throw new PickerFileTooLargeException(metadata.fileName, metadata.sizeBytes, maxFileSizeBytes);
|
|
619
|
+
}
|
|
620
|
+
String dataBase64 = readUriAsBase64(uri, maxFileSizeBytes, metadata.fileName);
|
|
621
|
+
long sizeBytes = metadata.sizeBytes >= 0L ? metadata.sizeBytes : estimateBase64ByteSize(dataBase64);
|
|
622
|
+
|
|
623
|
+
WritableMap file = Arguments.createMap();
|
|
624
|
+
file.putString("file_name", metadata.fileName);
|
|
625
|
+
file.putString("mime_type", metadata.mimeType);
|
|
626
|
+
file.putDouble("size", Math.max(0L, sizeBytes));
|
|
627
|
+
file.putString("data_base64", dataBase64);
|
|
628
|
+
files.pushMap(file);
|
|
629
|
+
logDebug(
|
|
630
|
+
"picked content fileName=" + metadata.fileName +
|
|
631
|
+
", mimeType=" + metadata.mimeType +
|
|
632
|
+
", size=" + sizeBytes +
|
|
633
|
+
", base64Length=" + dataBase64.length()
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return files;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Reads display name, MIME type, and byte size for one content URI. */
|
|
640
|
+
private PickedContentMetadata readPickedContentMetadata(
|
|
641
|
+
Uri uri,
|
|
642
|
+
String fallbackMimeType,
|
|
643
|
+
int index
|
|
644
|
+
) {
|
|
645
|
+
String fileName = null;
|
|
646
|
+
long sizeBytes = -1L;
|
|
647
|
+
ContentResolver contentResolver = reactContext.getContentResolver();
|
|
648
|
+
|
|
649
|
+
try (Cursor cursor = contentResolver.query(uri, null, null, null, null)) {
|
|
650
|
+
if (cursor != null && cursor.moveToFirst()) {
|
|
651
|
+
int displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
652
|
+
if (displayNameIndex >= 0 && !cursor.isNull(displayNameIndex)) {
|
|
653
|
+
fileName = cursor.getString(displayNameIndex);
|
|
654
|
+
}
|
|
655
|
+
int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
|
|
656
|
+
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
|
|
657
|
+
sizeBytes = cursor.getLong(sizeIndex);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch (RuntimeException error) {
|
|
661
|
+
logWarn("Could not read picker metadata for uri=" + uri, error);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
String mimeType = contentResolver.getType(uri);
|
|
665
|
+
return new PickedContentMetadata(
|
|
666
|
+
fileName == null || fileName.length() == 0 ? "attachment-" + (index + 1) : fileName,
|
|
667
|
+
mimeType == null || mimeType.length() == 0 ? fallbackMimeType : mimeType,
|
|
668
|
+
sizeBytes
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Reads a selected content URI into a bounded base64 payload. */
|
|
673
|
+
private String readUriAsBase64(
|
|
674
|
+
Uri uri,
|
|
675
|
+
long maxFileSizeBytes,
|
|
676
|
+
String fileName
|
|
677
|
+
) throws IOException, PickerFileTooLargeException {
|
|
678
|
+
ContentResolver contentResolver = reactContext.getContentResolver();
|
|
679
|
+
try (InputStream inputStream = contentResolver.openInputStream(uri);
|
|
680
|
+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
|
681
|
+
if (inputStream == null) {
|
|
682
|
+
throw new IOException("Could not open selected file.");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
byte[] buffer = new byte[BASE64_BUFFER_SIZE];
|
|
686
|
+
int bytesRead;
|
|
687
|
+
long totalBytes = 0L;
|
|
688
|
+
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
|
689
|
+
totalBytes += bytesRead;
|
|
690
|
+
if (maxFileSizeBytes > 0L && totalBytes > maxFileSizeBytes) {
|
|
691
|
+
throw new PickerFileTooLargeException(fileName, totalBytes, maxFileSizeBytes);
|
|
692
|
+
}
|
|
693
|
+
outputStream.write(buffer, 0, bytesRead);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Converts successful Activity result extras into the JS picked-file payload for all coalesced callers. */
|
|
701
|
+
private void resolvePickedFiles(List<Promise> promises, Intent data) {
|
|
702
|
+
String resultId = readStringExtra(data, EXTRA_RESULT_ID, null);
|
|
703
|
+
String fileName = readStringExtra(data, EXTRA_RESULT_FILE_NAME, null);
|
|
704
|
+
String mimeType = readStringExtra(data, EXTRA_RESULT_MIME_TYPE, DEFAULT_IMAGE_MIME_TYPE);
|
|
705
|
+
String dataBase64 = readStringExtra(data, EXTRA_RESULT_DATA_BASE64, null);
|
|
706
|
+
long size = readLongExtra(data, EXTRA_RESULT_SIZE, dataBase64 == null ? 0L : estimateBase64ByteSize(dataBase64));
|
|
707
|
+
SagepilotInAppCameraResultStore.PickedImage storedImage =
|
|
708
|
+
SagepilotInAppCameraResultStore.take(reactContext, resultId, fileName, mimeType, size);
|
|
709
|
+
|
|
710
|
+
if (storedImage != null) {
|
|
711
|
+
logDebug(
|
|
712
|
+
"resolving stored camera payload: fileName=" + storedImage.fileName +
|
|
713
|
+
", size=" + storedImage.sizeBytes +
|
|
714
|
+
", base64Length=" + storedImage.dataBase64.length()
|
|
715
|
+
);
|
|
716
|
+
for (Promise promise : promises) {
|
|
717
|
+
resolvePickedFile(
|
|
718
|
+
promise,
|
|
719
|
+
storedImage.fileName,
|
|
720
|
+
storedImage.mimeType,
|
|
721
|
+
storedImage.sizeBytes,
|
|
722
|
+
storedImage.dataBase64
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (resultId != null) {
|
|
729
|
+
logWarn("stored camera payload missing for resultId=" + resultId);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
for (Promise promise : promises) {
|
|
733
|
+
resolvePickedFile(promise, fileName, mimeType, size, dataBase64);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Resolves a successful camera payload into the JS picked-file shape. */
|
|
738
|
+
private void resolvePickedFile(
|
|
739
|
+
Promise promise,
|
|
740
|
+
String fileName,
|
|
741
|
+
String mimeType,
|
|
742
|
+
long size,
|
|
743
|
+
String dataBase64
|
|
744
|
+
) {
|
|
745
|
+
if (fileName == null || fileName.length() == 0 || dataBase64 == null || dataBase64.length() == 0) {
|
|
746
|
+
logWarn("camera payload missing fileName or base64 data");
|
|
747
|
+
rejectPromise(promise, ERROR_ENCODE_FAILED, "The photo could not be processed.");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
WritableMap file = Arguments.createMap();
|
|
752
|
+
file.putString("file_name", fileName);
|
|
753
|
+
file.putString("mime_type", mimeType);
|
|
754
|
+
file.putDouble("size", Math.max(0L, size));
|
|
755
|
+
file.putString("data_base64", dataBase64);
|
|
756
|
+
promise.resolve(file);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Checks the current Android runtime camera permission before direct native launch. */
|
|
760
|
+
private boolean hasCameraPermission() {
|
|
761
|
+
return ContextCompat.checkSelfPermission(reactContext, Manifest.permission.CAMERA)
|
|
762
|
+
== PackageManager.PERMISSION_GRANTED;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Checks whether the host device advertises at least one camera. */
|
|
766
|
+
private boolean hasCameraHardware() {
|
|
767
|
+
return reactContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Checks whether the SDK camera Activity class is packaged in the host app. */
|
|
771
|
+
private boolean hasCameraActivityClass() {
|
|
772
|
+
try {
|
|
773
|
+
Class.forName(CAMERA_ACTIVITY_CLASS_NAME);
|
|
774
|
+
return true;
|
|
775
|
+
} catch (ClassNotFoundException error) {
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Reads an integer option from the JS options map with a native fallback. */
|
|
781
|
+
private int readIntOption(ReadableMap options, String key, int fallback) {
|
|
782
|
+
if (options == null || !options.hasKey(key) || options.isNull(key)) {
|
|
783
|
+
return fallback;
|
|
784
|
+
}
|
|
785
|
+
return Math.max(1, (int) Math.round(options.getDouble(key)));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Reads a double option from the JS options map with a native fallback. */
|
|
789
|
+
private double readDoubleOption(ReadableMap options, String key, double fallback) {
|
|
790
|
+
if (options == null || !options.hasKey(key) || options.isNull(key)) {
|
|
791
|
+
return fallback;
|
|
792
|
+
}
|
|
793
|
+
return Math.max(0.0, Math.min(1.0, options.getDouble(key)));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Converts a JS quality ratio into Android's 0-100 JPEG quality range. */
|
|
797
|
+
private int qualityRatioToPercent(double qualityRatio) {
|
|
798
|
+
if (Double.isNaN(qualityRatio) || Double.isInfinite(qualityRatio)) {
|
|
799
|
+
return (int) Math.round(DEFAULT_IMAGE_QUALITY * 100.0);
|
|
800
|
+
}
|
|
801
|
+
double clampedQuality = Math.max(0.0, Math.min(1.0, qualityRatio));
|
|
802
|
+
return (int) Math.round(clampedQuality * 100.0);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Reads a long option from the JS options map with a native fallback. */
|
|
806
|
+
private long readLongOption(ReadableMap options, String key, long fallback) {
|
|
807
|
+
if (options == null || !options.hasKey(key) || options.isNull(key)) {
|
|
808
|
+
return fallback;
|
|
809
|
+
}
|
|
810
|
+
return Math.max(0L, Math.round(options.getDouble(key)));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Reads a boolean option from the JS options map with a native fallback. */
|
|
814
|
+
private boolean readBooleanOption(ReadableMap options, String key, boolean fallback) {
|
|
815
|
+
if (options == null || !options.hasKey(key) || options.isNull(key)) {
|
|
816
|
+
return fallback;
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
return options.getBoolean(key);
|
|
820
|
+
} catch (RuntimeException error) {
|
|
821
|
+
return fallback;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Reads a string extra from an Activity result with a fallback. */
|
|
826
|
+
private String readStringExtra(Intent data, String key, String fallback) {
|
|
827
|
+
if (data == null || !data.hasExtra(key)) {
|
|
828
|
+
return fallback;
|
|
829
|
+
}
|
|
830
|
+
String value = data.getStringExtra(key);
|
|
831
|
+
return value == null ? fallback : value;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Reads a long extra from an Activity result with a fallback. */
|
|
835
|
+
private long readLongExtra(Intent data, String key, long fallback) {
|
|
836
|
+
if (data == null || !data.hasExtra(key)) {
|
|
837
|
+
return fallback;
|
|
838
|
+
}
|
|
839
|
+
return data.getLongExtra(key, fallback);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Estimates byte size for a base64 payload when native size metadata is absent. */
|
|
843
|
+
private long estimateBase64ByteSize(String dataBase64) {
|
|
844
|
+
return (dataBase64.length() * 3L) / 4L;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Checks if a JS promise is already waiting for a camera result. */
|
|
848
|
+
private boolean hasPendingPromise() {
|
|
849
|
+
synchronized (pendingLock) {
|
|
850
|
+
return pendingPromise != null;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Stores the JS promise that will receive the next camera result. */
|
|
855
|
+
private void setPendingPromise(Promise promise) {
|
|
856
|
+
setPendingPromise(promise, 0L);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Stores the JS promise and file-size cap that will receive the next picker result. */
|
|
860
|
+
private void setPendingPromise(Promise promise, long maxFileSizeBytes) {
|
|
861
|
+
synchronized (pendingLock) {
|
|
862
|
+
pendingPromise = promise;
|
|
863
|
+
pendingMaxFileSizeBytes = maxFileSizeBytes;
|
|
864
|
+
pendingCameraCapture = false;
|
|
865
|
+
pendingCameraPromises.clear();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Stores the primary JS promise for a camera capture and starts a coalescing list. */
|
|
870
|
+
private void setPendingCameraPromise(Promise promise) {
|
|
871
|
+
synchronized (pendingLock) {
|
|
872
|
+
pendingPromise = promise;
|
|
873
|
+
pendingMaxFileSizeBytes = 0L;
|
|
874
|
+
pendingCameraCapture = true;
|
|
875
|
+
pendingCameraPromises.clear();
|
|
876
|
+
pendingCameraPromises.add(promise);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Adds a duplicate camera open request to the active capture instead of rejecting it. */
|
|
881
|
+
private boolean addPendingCameraPromise(Promise promise) {
|
|
882
|
+
synchronized (pendingLock) {
|
|
883
|
+
if (!pendingCameraCapture || pendingPromise == null) {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
pendingCameraPromises.add(promise);
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Takes and clears every promise waiting for the active camera capture. */
|
|
892
|
+
private List<Promise> takePendingCameraPromises() {
|
|
893
|
+
synchronized (pendingLock) {
|
|
894
|
+
if (!pendingCameraCapture) {
|
|
895
|
+
return new ArrayList<>();
|
|
896
|
+
}
|
|
897
|
+
List<Promise> promises = new ArrayList<>(pendingCameraPromises);
|
|
898
|
+
pendingPromise = null;
|
|
899
|
+
pendingMaxFileSizeBytes = 0L;
|
|
900
|
+
pendingCameraCapture = false;
|
|
901
|
+
pendingCameraPromises.clear();
|
|
902
|
+
return promises;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Takes and clears the pending promise so it can be settled exactly once. */
|
|
907
|
+
private Promise takePendingPromise() {
|
|
908
|
+
return takePendingPicker().promise;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Takes and clears the pending picker state so it can be settled exactly once. */
|
|
912
|
+
private PendingPicker takePendingPicker() {
|
|
913
|
+
synchronized (pendingLock) {
|
|
914
|
+
Promise promise = pendingPromise;
|
|
915
|
+
long maxFileSizeBytes = pendingMaxFileSizeBytes;
|
|
916
|
+
pendingPromise = null;
|
|
917
|
+
pendingMaxFileSizeBytes = 0L;
|
|
918
|
+
pendingCameraCapture = false;
|
|
919
|
+
pendingCameraPromises.clear();
|
|
920
|
+
return new PendingPicker(promise, maxFileSizeBytes);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Rejects and clears the pending promise when native ownership ends early. */
|
|
925
|
+
private void rejectPendingPromise(String code, String message, Throwable error) {
|
|
926
|
+
List<Promise> cameraPromises = takePendingCameraPromises();
|
|
927
|
+
if (!cameraPromises.isEmpty()) {
|
|
928
|
+
for (Promise promise : cameraPromises) {
|
|
929
|
+
rejectPromise(promise, code, message, error);
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
Promise promise = takePendingPromise();
|
|
935
|
+
if (promise == null) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
rejectPromise(promise, code, message, error);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Rejects a JS promise with a stable code and optional native cause. */
|
|
942
|
+
private void rejectPromise(Promise promise, String code, String message, Throwable error) {
|
|
943
|
+
logWarn("rejecting camera promise: code=" + code + ", message=" + message, error);
|
|
944
|
+
if (error == null) {
|
|
945
|
+
promise.reject(code, message);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
promise.reject(code, message, error);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Rejects a JS promise with a stable code. */
|
|
952
|
+
private void rejectPromise(Promise promise, String code, String message) {
|
|
953
|
+
rejectPromise(promise, code, message, null);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Writes a native debug log and mirrors it into the React Native console. */
|
|
957
|
+
private void logDebug(String message) {
|
|
958
|
+
Log.d(TAG, message);
|
|
959
|
+
SagepilotInAppCameraDebugEvents.debug(message);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Writes a native warning log and mirrors it into the React Native console. */
|
|
963
|
+
private void logWarn(String message) {
|
|
964
|
+
Log.w(TAG, message);
|
|
965
|
+
SagepilotInAppCameraDebugEvents.warn(message);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Writes a native warning log with a cause and mirrors it into the React Native console. */
|
|
969
|
+
private void logWarn(String message, Throwable error) {
|
|
970
|
+
Log.w(TAG, message, error);
|
|
971
|
+
SagepilotInAppCameraDebugEvents.warn(message);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Writes a native error log and mirrors it into the React Native console. */
|
|
975
|
+
private void logError(String message, Throwable error) {
|
|
976
|
+
Log.e(TAG, message, error);
|
|
977
|
+
SagepilotInAppCameraDebugEvents.error(message, error);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private static final class PendingPicker {
|
|
981
|
+
final Promise promise;
|
|
982
|
+
final long maxFileSizeBytes;
|
|
983
|
+
|
|
984
|
+
/** Holds pending picker state captured before clearing the shared pending slot. */
|
|
985
|
+
PendingPicker(Promise promise, long maxFileSizeBytes) {
|
|
986
|
+
this.promise = promise;
|
|
987
|
+
this.maxFileSizeBytes = maxFileSizeBytes;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private static final class PickedContentMetadata {
|
|
992
|
+
final String fileName;
|
|
993
|
+
final String mimeType;
|
|
994
|
+
final long sizeBytes;
|
|
995
|
+
|
|
996
|
+
/** Holds Android content metadata used to build a picked-file payload. */
|
|
997
|
+
PickedContentMetadata(String fileName, String mimeType, long sizeBytes) {
|
|
998
|
+
this.fileName = fileName;
|
|
999
|
+
this.mimeType = mimeType;
|
|
1000
|
+
this.sizeBytes = sizeBytes;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private static final class PickerFileTooLargeException extends Exception {
|
|
1005
|
+
private static final long serialVersionUID = 1L;
|
|
1006
|
+
|
|
1007
|
+
/** Creates a picker size-cap failure with file name and byte counts. */
|
|
1008
|
+
PickerFileTooLargeException(String fileName, long actualSizeBytes, long maxSizeBytes) {
|
|
1009
|
+
super("\"" + fileName + "\" is too large: " + actualSizeBytes + " > " + maxSizeBytes);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|