@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,1419 @@
1
+ package ai.sagepilot.reactnativecameraaddon;
2
+
3
+ import android.Manifest;
4
+ import android.app.Activity;
5
+ import android.app.Dialog;
6
+ import android.content.pm.PackageManager;
7
+ import android.content.res.ColorStateList;
8
+ import android.graphics.Color;
9
+ import android.graphics.Typeface;
10
+ import android.graphics.drawable.GradientDrawable;
11
+ import android.graphics.drawable.RippleDrawable;
12
+ import android.net.Uri;
13
+ import android.os.Build;
14
+ import android.util.Log;
15
+ import android.util.Size;
16
+ import android.view.Gravity;
17
+ import android.view.HapticFeedbackConstants;
18
+ import android.view.MotionEvent;
19
+ import android.view.ScaleGestureDetector;
20
+ import android.view.Surface;
21
+ import android.view.View;
22
+ import android.view.ViewGroup;
23
+ import android.view.Window;
24
+ import android.view.WindowManager;
25
+ import android.view.animation.DecelerateInterpolator;
26
+ import android.widget.FrameLayout;
27
+ import android.widget.ImageView;
28
+ import android.widget.LinearLayout;
29
+ import android.widget.TextView;
30
+ import androidx.activity.ComponentActivity;
31
+ import androidx.activity.OnBackPressedCallback;
32
+ import androidx.camera.core.Camera;
33
+ import androidx.camera.core.CameraInfoUnavailableException;
34
+ import androidx.camera.core.CameraSelector;
35
+ import androidx.camera.core.ImageCapture;
36
+ import androidx.camera.core.ImageCaptureException;
37
+ import androidx.camera.core.Preview;
38
+ import androidx.camera.core.ZoomState;
39
+ import androidx.camera.core.resolutionselector.ResolutionSelector;
40
+ import androidx.camera.core.resolutionselector.ResolutionStrategy;
41
+ import androidx.camera.lifecycle.ProcessCameraProvider;
42
+ import androidx.camera.view.PreviewView;
43
+ import androidx.core.graphics.Insets;
44
+ import androidx.core.content.ContextCompat;
45
+ import androidx.core.view.ViewCompat;
46
+ import androidx.core.view.WindowCompat;
47
+ import androidx.core.view.WindowInsetsCompat;
48
+ import androidx.lifecycle.LifecycleOwner;
49
+ import com.google.common.util.concurrent.ListenableFuture;
50
+ import java.io.File;
51
+ import java.io.IOException;
52
+ import java.util.Locale;
53
+ import java.util.concurrent.ExecutionException;
54
+ import java.util.concurrent.Executor;
55
+ import java.util.concurrent.ExecutorService;
56
+ import java.util.concurrent.Executors;
57
+ import java.util.concurrent.atomic.AtomicBoolean;
58
+
59
+ final class SagepilotInAppCameraOverlay {
60
+ private static final String TAG = "SagepilotInAppCamera";
61
+ private static final String CAMERA_CACHE_DIRECTORY_NAME = "sagepilot-camera";
62
+ private static final String PHOTO_FILE_PREFIX = "sagepilot-camera-";
63
+ private static final String PROCESSED_PHOTO_FILE_PREFIX = "sagepilot-camera-processed-";
64
+ private static final String PHOTO_FILE_EXTENSION = ".jpg";
65
+ private static final String IMAGE_MIME_TYPE = "image/jpeg";
66
+
67
+ private static final int CONTROL_MARGIN_DP = 24;
68
+ private static final int TOP_CONTROL_WIDTH_DP = 44;
69
+ private static final int TOP_CONTROL_HEIGHT_DP = 44;
70
+ private static final int SHUTTER_SIZE_DP = 82;
71
+ private static final int SHUTTER_INNER_SIZE_DP = 62;
72
+ private static final int SHUTTER_BOTTOM_MARGIN_DP = 42;
73
+ private static final int TOP_SCRIM_HEIGHT_DP = 132;
74
+ private static final int BOTTOM_SCRIM_HEIGHT_DP = 196;
75
+ private static final int PROCESSING_PANEL_WIDTH_DP = 156;
76
+ private static final int PROCESSING_PANEL_HEIGHT_DP = 48;
77
+ private static final long SHUTTER_PRESS_ANIMATION_MS = 90L;
78
+ private static final long SHUTTER_RELEASE_ANIMATION_MS = 150L;
79
+ private static final long CAPTURE_FLASH_IN_MS = 55L;
80
+ private static final long CAPTURE_FLASH_OUT_MS = 170L;
81
+ private static final float DEFAULT_ZOOM_RATIO = 1.0f;
82
+ private static final float MIN_ZOOM_STEP_DELTA = 0.01f;
83
+ private static final int ZOOM_TOUCH_LOG_POINTER_COUNT = 2;
84
+
85
+ private final Activity activity;
86
+ private final LifecycleOwner lifecycleOwner;
87
+ private final Listener listener;
88
+ private final int imageMaxDimension;
89
+ private final int imageQuality;
90
+ private final long imageMaxFileSizeBytes;
91
+ private final AtomicBoolean resultSettled = new AtomicBoolean(false);
92
+ private final AtomicBoolean captureInProgress = new AtomicBoolean(false);
93
+ private final DecelerateInterpolator controlInterpolator = new DecelerateInterpolator();
94
+
95
+ private FrameLayout rootView;
96
+ private PreviewView previewView;
97
+ private ImageView capturedPreviewView;
98
+ private View shutterButton;
99
+ private View shutterInnerButton;
100
+ private ImageView closeButton;
101
+ private ImageView switchButton;
102
+ private TextView cameraTitle;
103
+ private ImageView retakeButton;
104
+ private ImageView usePhotoButton;
105
+ private LinearLayout previewActionBar;
106
+ private View captureFlashView;
107
+ private View processingOverlay;
108
+ private Dialog overlayDialog;
109
+ private FrameLayout.LayoutParams topScrimParams;
110
+ private FrameLayout.LayoutParams bottomScrimParams;
111
+ private FrameLayout.LayoutParams titleParams;
112
+ private FrameLayout.LayoutParams closeParams;
113
+ private FrameLayout.LayoutParams switchParams;
114
+ private FrameLayout.LayoutParams shutterParams;
115
+ private FrameLayout.LayoutParams previewActionParams;
116
+ private ProcessCameraProvider cameraProvider;
117
+ private Camera camera;
118
+ private ImageCapture imageCapture;
119
+ private ScaleGestureDetector zoomGestureDetector;
120
+ private Executor mainExecutor;
121
+ private ExecutorService cameraExecutor;
122
+ private OnBackPressedCallback backCallback;
123
+ private int previousWindowFlags;
124
+ private int lensFacing = CameraSelector.LENS_FACING_BACK;
125
+ private File pendingCapturePhotoFile;
126
+ private File pendingPreviewPhotoFile;
127
+ private int safeTopInsetPx;
128
+ private int safeBottomInsetPx;
129
+ private int safeLeftInsetPx;
130
+ private int safeRightInsetPx;
131
+ private float zoomRatio = DEFAULT_ZOOM_RATIO;
132
+ private boolean showingPreview;
133
+
134
+ /** Creates a CameraX overlay bound to the host Activity instead of a separate Activity. */
135
+ SagepilotInAppCameraOverlay(
136
+ Activity activity,
137
+ int imageMaxDimension,
138
+ int imageQuality,
139
+ long imageMaxFileSizeBytes,
140
+ Listener listener
141
+ ) {
142
+ if (!(activity instanceof LifecycleOwner)) {
143
+ throw new IllegalArgumentException("Host Activity must implement LifecycleOwner.");
144
+ }
145
+ this.activity = activity;
146
+ this.lifecycleOwner = (LifecycleOwner) activity;
147
+ this.imageMaxDimension = Math.max(1, imageMaxDimension);
148
+ this.imageQuality = Math.max(0, Math.min(100, imageQuality));
149
+ this.imageMaxFileSizeBytes = Math.max(0L, imageMaxFileSizeBytes);
150
+ this.listener = listener;
151
+ }
152
+
153
+ /** Attaches the camera overlay above the React Native host view and starts CameraX. */
154
+ void show() {
155
+ if (!hasCameraPermission()) {
156
+ throw new IllegalStateException("Camera permission is required before showing the overlay.");
157
+ }
158
+
159
+ mainExecutor = ContextCompat.getMainExecutor(activity);
160
+ cameraExecutor = Executors.newSingleThreadExecutor();
161
+ configureWindowChrome();
162
+ installBackHandler();
163
+ cleanupStaleCameraFiles();
164
+ createCameraLayout();
165
+ attachRootView();
166
+ startCameraProvider();
167
+ }
168
+
169
+ /** Tears down the overlay without resolving the customer-facing picker promise. */
170
+ void destroySilently() {
171
+ resultSettled.set(true);
172
+ closeOverlay(true);
173
+ }
174
+
175
+ /** Tears down the overlay from any native module thread by hopping to the host UI thread. */
176
+ void destroySilentlyOnUiThread() {
177
+ activity.runOnUiThread(this::destroySilently);
178
+ }
179
+
180
+ /** Checks whether this overlay has not yet settled a customer result. */
181
+ boolean isActive() {
182
+ return !resultSettled.get();
183
+ }
184
+
185
+ /** Checks the runtime camera permission before touching CameraX. */
186
+ private boolean hasCameraPermission() {
187
+ return ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED;
188
+ }
189
+
190
+ /** Configures window flags needed while the camera overlay is visible. */
191
+ private void configureWindowChrome() {
192
+ Window window = activity.getWindow();
193
+ if (window == null) {
194
+ return;
195
+ }
196
+ previousWindowFlags = window.getAttributes().flags;
197
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
198
+ }
199
+
200
+ /** Restores window flags that were changed for the camera overlay. */
201
+ private void restoreWindowChrome() {
202
+ Window window = activity.getWindow();
203
+ if (window == null) {
204
+ return;
205
+ }
206
+ if ((previousWindowFlags & WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) == 0) {
207
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
208
+ }
209
+ }
210
+
211
+ /** Installs AndroidX back handling so hardware back maps to picker cancellation. */
212
+ private void installBackHandler() {
213
+ if (!(activity instanceof ComponentActivity)) {
214
+ return;
215
+ }
216
+ backCallback = new OnBackPressedCallback(true) {
217
+ /** Cancels the in-process camera overlay when the customer presses back. */
218
+ @Override
219
+ public void handleOnBackPressed() {
220
+ finishWithCancel();
221
+ }
222
+ };
223
+ ((ComponentActivity) activity).getOnBackPressedDispatcher().addCallback(lifecycleOwner, backCallback);
224
+ }
225
+
226
+ /** Builds the full-screen preview and minimal capture controls. */
227
+ private void createCameraLayout() {
228
+ rootView = new FrameLayout(activity);
229
+ rootView.setBackgroundColor(Color.BLACK);
230
+ rootView.setClipToPadding(false);
231
+ rootView.setClickable(true);
232
+ rootView.setFocusable(true);
233
+
234
+ previewView = new PreviewView(activity);
235
+ previewView.setImplementationMode(PreviewView.ImplementationMode.PERFORMANCE);
236
+ previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER);
237
+ previewView.setClickable(true);
238
+ installZoomGestureHandler();
239
+ rootView.addView(previewView, new FrameLayout.LayoutParams(
240
+ ViewGroup.LayoutParams.MATCH_PARENT,
241
+ ViewGroup.LayoutParams.MATCH_PARENT
242
+ ));
243
+
244
+ capturedPreviewView = createCapturedPreviewView();
245
+ rootView.addView(capturedPreviewView, new FrameLayout.LayoutParams(
246
+ ViewGroup.LayoutParams.MATCH_PARENT,
247
+ ViewGroup.LayoutParams.MATCH_PARENT
248
+ ));
249
+
250
+ captureFlashView = createCaptureFlashView();
251
+ rootView.addView(captureFlashView, new FrameLayout.LayoutParams(
252
+ ViewGroup.LayoutParams.MATCH_PARENT,
253
+ ViewGroup.LayoutParams.MATCH_PARENT
254
+ ));
255
+
256
+ topScrimParams = createTopScrimLayoutParams();
257
+ rootView.addView(createTopScrim(), topScrimParams);
258
+ bottomScrimParams = createBottomScrimLayoutParams();
259
+ rootView.addView(createBottomScrim(), bottomScrimParams);
260
+
261
+ cameraTitle = createCameraTitle();
262
+ titleParams = new FrameLayout.LayoutParams(
263
+ ViewGroup.LayoutParams.WRAP_CONTENT,
264
+ dp(TOP_CONTROL_HEIGHT_DP)
265
+ );
266
+ titleParams.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL;
267
+ rootView.addView(cameraTitle, titleParams);
268
+
269
+ closeButton = createIconButton(R.drawable.sagepilot_ic_x, "Close camera");
270
+ closeParams = new FrameLayout.LayoutParams(
271
+ dp(TOP_CONTROL_HEIGHT_DP),
272
+ dp(TOP_CONTROL_HEIGHT_DP)
273
+ );
274
+ closeParams.gravity = Gravity.TOP | Gravity.START;
275
+ rootView.addView(closeButton, closeParams);
276
+
277
+ switchButton = createIconButton(R.drawable.sagepilot_ic_switch_camera, "Switch camera");
278
+ switchParams = new FrameLayout.LayoutParams(
279
+ dp(TOP_CONTROL_WIDTH_DP),
280
+ dp(TOP_CONTROL_HEIGHT_DP)
281
+ );
282
+ switchParams.gravity = Gravity.TOP | Gravity.END;
283
+ rootView.addView(switchButton, switchParams);
284
+
285
+ shutterButton = createShutterButton();
286
+ shutterParams = new FrameLayout.LayoutParams(dp(SHUTTER_SIZE_DP), dp(SHUTTER_SIZE_DP));
287
+ shutterParams.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
288
+ rootView.addView(shutterButton, shutterParams);
289
+
290
+ previewActionBar = createPreviewActionBar();
291
+ previewActionParams = new FrameLayout.LayoutParams(
292
+ ViewGroup.LayoutParams.MATCH_PARENT,
293
+ dp(64)
294
+ );
295
+ previewActionParams.gravity = Gravity.BOTTOM;
296
+ rootView.addView(previewActionBar, previewActionParams);
297
+
298
+ processingOverlay = createProcessingOverlay();
299
+ FrameLayout.LayoutParams processingParams = new FrameLayout.LayoutParams(
300
+ dp(PROCESSING_PANEL_WIDTH_DP),
301
+ dp(PROCESSING_PANEL_HEIGHT_DP)
302
+ );
303
+ processingParams.gravity = Gravity.CENTER;
304
+ rootView.addView(processingOverlay, processingParams);
305
+
306
+ closeButton.setOnClickListener(view -> finishWithCancel());
307
+ switchButton.setOnClickListener(view -> switchCamera());
308
+ shutterButton.setOnTouchListener(createShutterTouchListener());
309
+ shutterButton.setOnClickListener(view -> {
310
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
311
+ capturePhoto();
312
+ });
313
+ retakeButton.setOnClickListener(view -> retakePhoto());
314
+ usePhotoButton.setOnClickListener(view -> acceptPreviewPhoto());
315
+ installSafeAreaInsets(rootView);
316
+ applySafeAreaInsetsToControls();
317
+ updateControlState(false);
318
+ }
319
+
320
+ /** Installs pinch-to-zoom handling on the live CameraX preview. */
321
+ private void installZoomGestureHandler() {
322
+ if (previewView == null) {
323
+ return;
324
+ }
325
+ logDebug("Overlay installing CameraX zoom gesture handler");
326
+ zoomGestureDetector = new ScaleGestureDetector(activity, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
327
+ /** Logs the active CameraX zoom range when a pinch starts. */
328
+ @Override
329
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
330
+ logZoomGestureBegin();
331
+ return true;
332
+ }
333
+
334
+ /** Applies each pinch gesture step to CameraX zoom control. */
335
+ @Override
336
+ public boolean onScale(ScaleGestureDetector detector) {
337
+ return handleZoomScale(detector.getScaleFactor());
338
+ }
339
+
340
+ /** Logs the final zoom ratio when a pinch finishes. */
341
+ @Override
342
+ public void onScaleEnd(ScaleGestureDetector detector) {
343
+ logDebug("Overlay CameraX zoom gesture end: zoomRatio=" + zoomRatio);
344
+ }
345
+ });
346
+ previewView.setOnTouchListener((view, event) -> {
347
+ boolean canHandleZoom = canHandleZoomGesture();
348
+ logZoomTouchEvent(event, canHandleZoom);
349
+ if (!canHandleZoom) {
350
+ return false;
351
+ }
352
+ ScaleGestureDetector detector = zoomGestureDetector;
353
+ if (detector == null) {
354
+ return false;
355
+ }
356
+ detector.onTouchEvent(event);
357
+ return true;
358
+ });
359
+ }
360
+
361
+ /** Attaches the overlay root in a top-level app dialog above React Native Modal windows. */
362
+ private void attachRootView() {
363
+ if (rootView == null) {
364
+ throw new IllegalStateException("Could not attach the camera overlay.");
365
+ }
366
+
367
+ overlayDialog = new Dialog(activity, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
368
+ overlayDialog.setContentView(rootView);
369
+ overlayDialog.setCanceledOnTouchOutside(false);
370
+ overlayDialog.setOnCancelListener(dialog -> finishWithCancel());
371
+ Window window = overlayDialog.getWindow();
372
+ if (window != null) {
373
+ window.setBackgroundDrawableResource(android.R.color.black);
374
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
375
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
376
+ WindowCompat.setDecorFitsSystemWindows(window, false);
377
+ }
378
+
379
+ overlayDialog.show();
380
+ Window shownWindow = overlayDialog.getWindow();
381
+ if (shownWindow != null) {
382
+ shownWindow.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
383
+ }
384
+ ViewCompat.requestApplyInsets(rootView);
385
+ rootView.bringToFront();
386
+ rootView.requestFocus();
387
+ logDebug("Overlay dialog attached above host window");
388
+ }
389
+
390
+ /** Creates the dark top gradient that keeps controls legible over the preview. */
391
+ private View createTopScrim() {
392
+ View scrim = new View(activity);
393
+ scrim.setBackground(createVerticalGradient(0xA6000000, 0x00000000, GradientDrawable.Orientation.TOP_BOTTOM));
394
+ return scrim;
395
+ }
396
+
397
+ /** Creates the dark bottom gradient that visually anchors the shutter control. */
398
+ private View createBottomScrim() {
399
+ View scrim = new View(activity);
400
+ scrim.setBackground(createVerticalGradient(0x00000000, 0xCC000000, GradientDrawable.Orientation.TOP_BOTTOM));
401
+ return scrim;
402
+ }
403
+
404
+ /** Creates the full-screen still preview used after a photo is captured. */
405
+ private ImageView createCapturedPreviewView() {
406
+ ImageView imageView = new ImageView(activity);
407
+ imageView.setBackgroundColor(Color.BLACK);
408
+ imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
409
+ imageView.setVisibility(View.GONE);
410
+ imageView.setAlpha(0.0f);
411
+ return imageView;
412
+ }
413
+
414
+ /** Returns layout params for the camera top gradient. */
415
+ private FrameLayout.LayoutParams createTopScrimLayoutParams() {
416
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
417
+ ViewGroup.LayoutParams.MATCH_PARENT,
418
+ dp(TOP_SCRIM_HEIGHT_DP)
419
+ );
420
+ params.gravity = Gravity.TOP;
421
+ return params;
422
+ }
423
+
424
+ /** Returns layout params for the camera bottom gradient. */
425
+ private FrameLayout.LayoutParams createBottomScrimLayoutParams() {
426
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
427
+ ViewGroup.LayoutParams.MATCH_PARENT,
428
+ dp(BOTTOM_SCRIM_HEIGHT_DP)
429
+ );
430
+ params.gravity = Gravity.BOTTOM;
431
+ return params;
432
+ }
433
+
434
+ /** Subscribes the full-screen overlay to system-bar and cutout inset changes. */
435
+ private void installSafeAreaInsets(FrameLayout root) {
436
+ ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> {
437
+ Insets insets = windowInsets.getInsets(
438
+ WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()
439
+ );
440
+ updateSafeAreaInsets(insets);
441
+ return windowInsets;
442
+ });
443
+ ViewCompat.requestApplyInsets(root);
444
+ }
445
+
446
+ /** Stores the latest safe-area insets and reapplies camera control margins. */
447
+ private void updateSafeAreaInsets(Insets insets) {
448
+ safeTopInsetPx = Math.max(0, insets.top);
449
+ safeBottomInsetPx = Math.max(0, insets.bottom);
450
+ safeLeftInsetPx = Math.max(0, insets.left);
451
+ safeRightInsetPx = Math.max(0, insets.right);
452
+ applySafeAreaInsetsToControls();
453
+ }
454
+
455
+ /** Offsets camera controls away from status bars, cutouts, and navigation bars. */
456
+ private void applySafeAreaInsetsToControls() {
457
+ int controlMargin = dp(CONTROL_MARGIN_DP);
458
+ if (topScrimParams != null) {
459
+ topScrimParams.height = dp(TOP_SCRIM_HEIGHT_DP) + safeTopInsetPx;
460
+ }
461
+ if (bottomScrimParams != null) {
462
+ bottomScrimParams.height = dp(BOTTOM_SCRIM_HEIGHT_DP) + safeBottomInsetPx;
463
+ }
464
+ if (titleParams != null) {
465
+ titleParams.setMargins(0, safeTopInsetPx + controlMargin, 0, 0);
466
+ }
467
+ if (closeParams != null) {
468
+ closeParams.setMargins(safeLeftInsetPx + controlMargin, safeTopInsetPx + controlMargin, 0, 0);
469
+ }
470
+ if (switchParams != null) {
471
+ switchParams.setMargins(0, safeTopInsetPx + controlMargin, safeRightInsetPx + controlMargin, 0);
472
+ }
473
+ if (shutterParams != null) {
474
+ shutterParams.setMargins(0, 0, 0, safeBottomInsetPx + dp(SHUTTER_BOTTOM_MARGIN_DP));
475
+ }
476
+ if (previewActionParams != null) {
477
+ previewActionParams.setMargins(
478
+ safeLeftInsetPx + controlMargin,
479
+ 0,
480
+ safeRightInsetPx + controlMargin,
481
+ safeBottomInsetPx + dp(SHUTTER_BOTTOM_MARGIN_DP)
482
+ );
483
+ }
484
+ if (rootView != null) {
485
+ rootView.requestLayout();
486
+ }
487
+ }
488
+
489
+ /** Creates the centered camera title. */
490
+ private TextView createCameraTitle() {
491
+ TextView title = new TextView(activity);
492
+ title.setText("Camera");
493
+ title.setTextColor(Color.WHITE);
494
+ title.setTextSize(16);
495
+ title.setTypeface(Typeface.DEFAULT_BOLD);
496
+ title.setGravity(Gravity.CENTER);
497
+ title.setIncludeFontPadding(false);
498
+ title.setAlpha(0.92f);
499
+ return title;
500
+ }
501
+
502
+ /** Creates a compact icon control for the top camera actions. */
503
+ private ImageView createIconButton(int iconResourceId, String contentDescription) {
504
+ ImageView button = new ImageView(activity);
505
+ button.setImageResource(iconResourceId);
506
+ button.setColorFilter(Color.WHITE);
507
+ button.setContentDescription(contentDescription);
508
+ button.setScaleType(ImageView.ScaleType.CENTER);
509
+ button.setPadding(dp(10), dp(10), dp(10), dp(10));
510
+ button.setClickable(true);
511
+ button.setFocusable(true);
512
+ button.setBackground(createRoundedBackground(0x66000000, 0x44FFFFFF, 22, 1));
513
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
514
+ button.setForeground(createRippleDrawable(0x33FFFFFF, 22));
515
+ }
516
+ button.setElevation(dp(3));
517
+ return button;
518
+ }
519
+
520
+ /** Creates the circular shutter control. */
521
+ private View createShutterButton() {
522
+ FrameLayout button = new FrameLayout(activity);
523
+ button.setContentDescription("Take photo");
524
+ button.setClickable(true);
525
+ button.setFocusable(true);
526
+ button.setBackground(createOvalBackground(0x22FFFFFF, 0xFFFFFFFF, 4));
527
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
528
+ button.setForeground(createOvalRippleDrawable(0x33FFFFFF));
529
+ }
530
+ button.setElevation(dp(8));
531
+
532
+ shutterInnerButton = new View(activity);
533
+ shutterInnerButton.setBackground(createOvalBackground(Color.WHITE, 0x00000000, 0));
534
+ FrameLayout.LayoutParams innerParams = new FrameLayout.LayoutParams(
535
+ dp(SHUTTER_INNER_SIZE_DP),
536
+ dp(SHUTTER_INNER_SIZE_DP)
537
+ );
538
+ innerParams.gravity = Gravity.CENTER;
539
+ button.addView(shutterInnerButton, innerParams);
540
+ return button;
541
+ }
542
+
543
+ /** Creates the white flash layer shown immediately when capture starts. */
544
+ private View createCaptureFlashView() {
545
+ View flash = new View(activity);
546
+ flash.setBackgroundColor(Color.WHITE);
547
+ flash.setAlpha(0.0f);
548
+ flash.setVisibility(View.GONE);
549
+ flash.setClickable(false);
550
+ return flash;
551
+ }
552
+
553
+ /** Creates the transient processing panel shown while CameraX saves and encodes. */
554
+ private View createProcessingOverlay() {
555
+ TextView overlay = new TextView(activity);
556
+ overlay.setText("Processing...");
557
+ overlay.setTextColor(Color.WHITE);
558
+ overlay.setTextSize(14);
559
+ overlay.setTypeface(Typeface.DEFAULT_BOLD);
560
+ overlay.setGravity(Gravity.CENTER);
561
+ overlay.setIncludeFontPadding(false);
562
+ overlay.setBackground(createRoundedBackground(0xB3000000, 0x33FFFFFF, 24, 1));
563
+ overlay.setAlpha(0.0f);
564
+ overlay.setVisibility(View.GONE);
565
+ overlay.setElevation(dp(10));
566
+ return overlay;
567
+ }
568
+
569
+ /** Creates the preview action row shown after capture. */
570
+ private LinearLayout createPreviewActionBar() {
571
+ LinearLayout actionBar = new LinearLayout(activity);
572
+ actionBar.setOrientation(LinearLayout.HORIZONTAL);
573
+ actionBar.setGravity(Gravity.CENTER);
574
+ actionBar.setVisibility(View.GONE);
575
+ actionBar.setAlpha(0.0f);
576
+
577
+ retakeButton = createPreviewActionButton(R.drawable.sagepilot_ic_rotate_ccw, "Retake photo", false);
578
+ usePhotoButton = createPreviewActionButton(R.drawable.sagepilot_ic_check, "Use photo", true);
579
+
580
+ LinearLayout.LayoutParams retakeParams = new LinearLayout.LayoutParams(dp(62), dp(62));
581
+ retakeParams.setMargins(0, 0, dp(18), 0);
582
+ actionBar.addView(retakeButton, retakeParams);
583
+
584
+ LinearLayout.LayoutParams useParams = new LinearLayout.LayoutParams(dp(62), dp(62));
585
+ useParams.setMargins(dp(18), 0, 0, 0);
586
+ actionBar.addView(usePhotoButton, useParams);
587
+ return actionBar;
588
+ }
589
+
590
+ /** Creates one icon-only action button for captured-photo preview decisions. */
591
+ private ImageView createPreviewActionButton(int iconResourceId, String contentDescription, boolean primary) {
592
+ ImageView button = new ImageView(activity);
593
+ button.setImageResource(iconResourceId);
594
+ button.setColorFilter(primary ? Color.BLACK : Color.WHITE);
595
+ button.setContentDescription(contentDescription);
596
+ button.setScaleType(ImageView.ScaleType.CENTER);
597
+ button.setPadding(dp(17), dp(17), dp(17), dp(17));
598
+ button.setClickable(true);
599
+ button.setFocusable(true);
600
+ button.setBackground(createOvalBackground(primary ? Color.WHITE : 0x66000000, 0x44FFFFFF, primary ? 2 : 1));
601
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
602
+ button.setForeground(createOvalRippleDrawable(primary ? 0x33000000 : 0x33FFFFFF));
603
+ }
604
+ button.setElevation(dp(primary ? 8 : 4));
605
+ return button;
606
+ }
607
+
608
+ /** Creates touch feedback for the shutter while preserving normal click dispatch. */
609
+ private View.OnTouchListener createShutterTouchListener() {
610
+ return (view, event) -> {
611
+ if (!view.isEnabled()) {
612
+ return false;
613
+ }
614
+
615
+ int action = event.getActionMasked();
616
+ if (action == MotionEvent.ACTION_DOWN) {
617
+ animateShutterPressed(true);
618
+ } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
619
+ animateShutterPressed(false);
620
+ }
621
+ return false;
622
+ };
623
+ }
624
+
625
+ /** Animates the shutter between resting and pressed states. */
626
+ private void animateShutterPressed(boolean pressed) {
627
+ if (shutterButton == null) {
628
+ return;
629
+ }
630
+
631
+ float buttonScale = pressed ? 0.90f : 1.0f;
632
+ float innerScale = pressed ? 0.82f : 1.0f;
633
+ long duration = pressed ? SHUTTER_PRESS_ANIMATION_MS : SHUTTER_RELEASE_ANIMATION_MS;
634
+
635
+ shutterButton.animate()
636
+ .scaleX(buttonScale)
637
+ .scaleY(buttonScale)
638
+ .setDuration(duration)
639
+ .setInterpolator(controlInterpolator)
640
+ .start();
641
+
642
+ if (shutterInnerButton != null) {
643
+ shutterInnerButton.animate()
644
+ .scaleX(innerScale)
645
+ .scaleY(innerScale)
646
+ .setDuration(duration)
647
+ .setInterpolator(controlInterpolator)
648
+ .start();
649
+ }
650
+ }
651
+
652
+ /** Plays immediate camera feedback before the slower file save/processing work. */
653
+ private void playCaptureFeedback() {
654
+ animateShutterPressed(false);
655
+ if (captureFlashView == null) {
656
+ return;
657
+ }
658
+
659
+ captureFlashView.animate().cancel();
660
+ captureFlashView.setAlpha(0.0f);
661
+ captureFlashView.setVisibility(View.VISIBLE);
662
+ captureFlashView.animate()
663
+ .alpha(0.36f)
664
+ .setDuration(CAPTURE_FLASH_IN_MS)
665
+ .setInterpolator(controlInterpolator)
666
+ .withEndAction(() -> captureFlashView.animate()
667
+ .alpha(0.0f)
668
+ .setDuration(CAPTURE_FLASH_OUT_MS)
669
+ .setInterpolator(controlInterpolator)
670
+ .withEndAction(() -> captureFlashView.setVisibility(View.GONE))
671
+ .start())
672
+ .start();
673
+ }
674
+
675
+ /** Shows or hides the processing state without blocking capture feedback. */
676
+ private void setProcessingOverlayVisible(boolean visible) {
677
+ if (processingOverlay == null) {
678
+ return;
679
+ }
680
+
681
+ processingOverlay.animate().cancel();
682
+ if (visible) {
683
+ processingOverlay.setVisibility(View.VISIBLE);
684
+ processingOverlay.animate()
685
+ .alpha(1.0f)
686
+ .setDuration(120L)
687
+ .setInterpolator(controlInterpolator)
688
+ .start();
689
+ return;
690
+ }
691
+
692
+ processingOverlay.animate()
693
+ .alpha(0.0f)
694
+ .setDuration(120L)
695
+ .setInterpolator(controlInterpolator)
696
+ .withEndAction(() -> processingOverlay.setVisibility(View.GONE))
697
+ .start();
698
+ }
699
+
700
+ /** Creates a rounded drawable used by camera controls. */
701
+ private GradientDrawable createRoundedBackground(int fillColor, int strokeColor, int radiusDp, int strokeWidthDp) {
702
+ GradientDrawable drawable = new GradientDrawable();
703
+ drawable.setShape(GradientDrawable.RECTANGLE);
704
+ drawable.setColor(fillColor);
705
+ drawable.setCornerRadius(dp(radiusDp));
706
+ drawable.setStroke(dp(strokeWidthDp), strokeColor);
707
+ return drawable;
708
+ }
709
+
710
+ /** Creates a circular drawable used by the shutter control. */
711
+ private GradientDrawable createOvalBackground(int fillColor, int strokeColor, int strokeWidthDp) {
712
+ GradientDrawable drawable = new GradientDrawable();
713
+ drawable.setShape(GradientDrawable.OVAL);
714
+ drawable.setColor(fillColor);
715
+ if (strokeWidthDp > 0) {
716
+ drawable.setStroke(dp(strokeWidthDp), strokeColor);
717
+ }
718
+ return drawable;
719
+ }
720
+
721
+ /** Creates a vertical gradient drawable for camera chrome overlays. */
722
+ private GradientDrawable createVerticalGradient(
723
+ int startColor,
724
+ int endColor,
725
+ GradientDrawable.Orientation orientation
726
+ ) {
727
+ return new GradientDrawable(orientation, new int[] { startColor, endColor });
728
+ }
729
+
730
+ /** Creates a rounded ripple drawable for top controls on modern Android. */
731
+ private RippleDrawable createRippleDrawable(int rippleColor, int radiusDp) {
732
+ return new RippleDrawable(
733
+ ColorStateList.valueOf(rippleColor),
734
+ null,
735
+ createRoundedBackground(Color.WHITE, 0x00000000, radiusDp, 0)
736
+ );
737
+ }
738
+
739
+ /** Creates a circular ripple drawable for the shutter on modern Android. */
740
+ private RippleDrawable createOvalRippleDrawable(int rippleColor) {
741
+ return new RippleDrawable(
742
+ ColorStateList.valueOf(rippleColor),
743
+ null,
744
+ createOvalBackground(Color.WHITE, 0x00000000, 0)
745
+ );
746
+ }
747
+
748
+ /** Starts CameraX provider acquisition and binds preview when ready. */
749
+ private void startCameraProvider() {
750
+ logDebug("Overlay requesting CameraX provider");
751
+ ListenableFuture<ProcessCameraProvider> providerFuture = ProcessCameraProvider.getInstance(activity);
752
+ providerFuture.addListener(() -> handleCameraProviderReady(providerFuture), mainExecutor);
753
+ }
754
+
755
+ /** Handles the asynchronous CameraX provider result. */
756
+ private void handleCameraProviderReady(ListenableFuture<ProcessCameraProvider> providerFuture) {
757
+ if (resultSettled.get()) {
758
+ return;
759
+ }
760
+ try {
761
+ cameraProvider = providerFuture.get();
762
+ logDebug("Overlay CameraX provider ready");
763
+ selectAvailableLens();
764
+ bindCameraUseCases();
765
+ } catch (InterruptedException error) {
766
+ Thread.currentThread().interrupt();
767
+ logError("Overlay CameraX provider startup interrupted", error);
768
+ finishWithError(SagepilotInAppCameraModule.ERROR_CAMERA_UNAVAILABLE, "Camera startup was interrupted.", error);
769
+ } catch (ExecutionException | CameraInfoUnavailableException | RuntimeException error) {
770
+ logError("Overlay CameraX provider startup failed", error);
771
+ finishWithError(SagepilotInAppCameraModule.ERROR_CAMERA_UNAVAILABLE, "Could not start the in-app camera.", error);
772
+ }
773
+ }
774
+
775
+ /** Selects the restored lens when available, otherwise falls back to any available camera. */
776
+ private void selectAvailableLens() throws CameraInfoUnavailableException {
777
+ if (hasCameraWithLens(lensFacing)) {
778
+ logDebug("Overlay using requested lens: " + lensFacing);
779
+ return;
780
+ }
781
+ if (hasCameraWithLens(CameraSelector.LENS_FACING_BACK)) {
782
+ lensFacing = CameraSelector.LENS_FACING_BACK;
783
+ logDebug("Overlay falling back to back camera");
784
+ return;
785
+ }
786
+ if (hasCameraWithLens(CameraSelector.LENS_FACING_FRONT)) {
787
+ lensFacing = CameraSelector.LENS_FACING_FRONT;
788
+ logDebug("Overlay falling back to front camera");
789
+ return;
790
+ }
791
+ throw new CameraInfoUnavailableException("No available camera found.");
792
+ }
793
+
794
+ /** Binds Preview and ImageCapture to the host Activity lifecycle. */
795
+ private void bindCameraUseCases() {
796
+ if (cameraProvider == null || previewView == null || resultSettled.get()) {
797
+ return;
798
+ }
799
+
800
+ try {
801
+ int targetRotation = getTargetRotation();
802
+ Preview preview = new Preview.Builder()
803
+ .setTargetRotation(targetRotation)
804
+ .build();
805
+ ImageCapture nextImageCapture = new ImageCapture.Builder()
806
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
807
+ .setResolutionSelector(createCaptureResolutionSelector())
808
+ .setTargetRotation(targetRotation)
809
+ .build();
810
+
811
+ cameraProvider.unbindAll();
812
+ camera = null;
813
+ imageCapture = null;
814
+ camera = cameraProvider.bindToLifecycle(lifecycleOwner, createCameraSelector(lensFacing), preview, nextImageCapture);
815
+ preview.setSurfaceProvider(previewView.getSurfaceProvider());
816
+ imageCapture = nextImageCapture;
817
+ applyZoomToBoundCamera();
818
+ logDebug("Overlay CameraX use cases bound: lensFacing=" + lensFacing + ", targetRotation=" + targetRotation);
819
+ updateControlState(true);
820
+ } catch (RuntimeException error) {
821
+ logError("Overlay could not bind CameraX use cases", error);
822
+ finishWithError(SagepilotInAppCameraModule.ERROR_CAMERA_UNAVAILABLE, "Could not bind the in-app camera.", error);
823
+ }
824
+ }
825
+
826
+ /** Toggles between front and back cameras when both lenses are available. */
827
+ private void switchCamera() {
828
+ if (!canSwitchCamera() || captureInProgress.get()) {
829
+ return;
830
+ }
831
+ lensFacing = lensFacing == CameraSelector.LENS_FACING_BACK
832
+ ? CameraSelector.LENS_FACING_FRONT
833
+ : CameraSelector.LENS_FACING_BACK;
834
+ logDebug("Overlay switching camera: nextLensFacing=" + lensFacing);
835
+ zoomRatio = DEFAULT_ZOOM_RATIO;
836
+ updateControlState(false);
837
+ bindCameraUseCases();
838
+ }
839
+
840
+ /** Applies the persisted zoom ratio after CameraX binds or rebinds a camera. */
841
+ private void applyZoomToBoundCamera() {
842
+ Camera activeCamera = camera;
843
+ if (activeCamera == null) {
844
+ logWarn("Overlay CameraX zoom apply skipped: activeCamera=null");
845
+ return;
846
+ }
847
+ ZoomState zoomState = activeCamera.getCameraInfo().getZoomState().getValue();
848
+ if (!isUsableZoomState(zoomState)) {
849
+ logWarn("Overlay CameraX zoom apply skipped: unusable zoomState=" + zoomState);
850
+ zoomRatio = DEFAULT_ZOOM_RATIO;
851
+ return;
852
+ }
853
+ float nextZoomRatio = clampZoomRatio(zoomRatio, zoomState.getMinZoomRatio(), zoomState.getMaxZoomRatio());
854
+ zoomRatio = nextZoomRatio;
855
+ activeCamera.getCameraControl().setZoomRatio(nextZoomRatio);
856
+ logDebug(
857
+ "Overlay CameraX zoom applied after bind: zoomRatio=" + nextZoomRatio +
858
+ ", min=" + zoomState.getMinZoomRatio() +
859
+ ", max=" + zoomState.getMaxZoomRatio()
860
+ );
861
+ }
862
+
863
+ /** Applies a pinch scale factor to the active CameraX zoom ratio. */
864
+ private boolean handleZoomScale(float scaleFactor) {
865
+ Camera activeCamera = camera;
866
+ if (!canHandleZoomGesture()) {
867
+ logWarn(
868
+ "Overlay CameraX zoom scale ignored: canHandle=false, camera=" + activeCamera +
869
+ ", showingPreview=" + showingPreview +
870
+ ", captureInProgress=" + captureInProgress.get() +
871
+ ", resultSettled=" + resultSettled.get()
872
+ );
873
+ return false;
874
+ }
875
+ if (activeCamera == null || scaleFactor <= 0.0f) {
876
+ logWarn("Overlay CameraX zoom scale ignored: camera=" + activeCamera + ", scaleFactor=" + scaleFactor);
877
+ return false;
878
+ }
879
+
880
+ ZoomState zoomState = activeCamera.getCameraInfo().getZoomState().getValue();
881
+ if (!isUsableZoomState(zoomState)) {
882
+ logWarn("Overlay CameraX zoom scale ignored: unusable zoomState=" + zoomState);
883
+ return false;
884
+ }
885
+
886
+ float currentZoomRatio = zoomState.getZoomRatio();
887
+ float nextZoomRatio = clampZoomRatio(
888
+ currentZoomRatio * scaleFactor,
889
+ zoomState.getMinZoomRatio(),
890
+ zoomState.getMaxZoomRatio()
891
+ );
892
+ if (Math.abs(nextZoomRatio - currentZoomRatio) < MIN_ZOOM_STEP_DELTA) {
893
+ return false;
894
+ }
895
+
896
+ zoomRatio = nextZoomRatio;
897
+ activeCamera.getCameraControl().setZoomRatio(nextZoomRatio);
898
+ return true;
899
+ }
900
+
901
+ /** Checks whether the live preview should consume touch events for zoom. */
902
+ private boolean canHandleZoomGesture() {
903
+ return !showingPreview && !captureInProgress.get() && !resultSettled.get();
904
+ }
905
+
906
+ /** Logs the first useful touch signals that should start a pinch gesture. */
907
+ private void logZoomTouchEvent(MotionEvent event, boolean canHandleZoom) {
908
+ if (event == null || event.getPointerCount() < ZOOM_TOUCH_LOG_POINTER_COUNT) {
909
+ return;
910
+ }
911
+ int action = event.getActionMasked();
912
+ if (action != MotionEvent.ACTION_POINTER_DOWN) {
913
+ return;
914
+ }
915
+ logDebug(
916
+ "Overlay CameraX zoom touch: action=" + motionActionToString(action) +
917
+ ", pointers=" + event.getPointerCount() +
918
+ ", canHandle=" + canHandleZoom +
919
+ ", detectorInProgress=" + (zoomGestureDetector != null && zoomGestureDetector.isInProgress())
920
+ );
921
+ }
922
+
923
+ /** Logs the current CameraX zoom range when a pinch gesture starts. */
924
+ private void logZoomGestureBegin() {
925
+ ZoomState zoomState = camera == null ? null : camera.getCameraInfo().getZoomState().getValue();
926
+ if (!isUsableZoomState(zoomState)) {
927
+ logWarn("Overlay CameraX zoom gesture begin: unusable zoomState=" + zoomState + ", camera=" + camera);
928
+ return;
929
+ }
930
+ logDebug(
931
+ "Overlay CameraX zoom gesture begin: current=" + zoomState.getZoomRatio() +
932
+ ", min=" + zoomState.getMinZoomRatio() +
933
+ ", max=" + zoomState.getMaxZoomRatio()
934
+ );
935
+ }
936
+
937
+ /** Converts MotionEvent action constants into readable debug labels. */
938
+ private String motionActionToString(int action) {
939
+ switch (action) {
940
+ case MotionEvent.ACTION_DOWN:
941
+ return "DOWN";
942
+ case MotionEvent.ACTION_POINTER_DOWN:
943
+ return "POINTER_DOWN";
944
+ case MotionEvent.ACTION_MOVE:
945
+ return "MOVE";
946
+ case MotionEvent.ACTION_POINTER_UP:
947
+ return "POINTER_UP";
948
+ case MotionEvent.ACTION_UP:
949
+ return "UP";
950
+ case MotionEvent.ACTION_CANCEL:
951
+ return "CANCEL";
952
+ default:
953
+ return String.valueOf(action);
954
+ }
955
+ }
956
+
957
+ /** Checks whether CameraX has reported a usable zoom range for this lens. */
958
+ private boolean isUsableZoomState(ZoomState zoomState) {
959
+ return zoomState != null &&
960
+ zoomState.getMinZoomRatio() > 0.0f &&
961
+ zoomState.getMaxZoomRatio() >= zoomState.getMinZoomRatio();
962
+ }
963
+
964
+ /** Clamps a zoom ratio to the active lens range reported by CameraX. */
965
+ private float clampZoomRatio(float value, float minZoomRatio, float maxZoomRatio) {
966
+ return Math.max(minZoomRatio, Math.min(maxZoomRatio, value));
967
+ }
968
+
969
+ /** Captures a single JPEG to SDK cache storage. */
970
+ private void capturePhoto() {
971
+ ImageCapture capture = imageCapture;
972
+ if (capture == null || !captureInProgress.compareAndSet(false, true)) {
973
+ logWarn("Overlay capturePhoto ignored: capture=" + capture + ", captureInProgress=" + captureInProgress.get());
974
+ return;
975
+ }
976
+
977
+ logDebug("Overlay capturePhoto started");
978
+ updateControlState(false);
979
+ playCaptureFeedback();
980
+ setProcessingOverlayVisible(true);
981
+ File photoFile;
982
+ try {
983
+ photoFile = createOutputFile();
984
+ pendingCapturePhotoFile = photoFile;
985
+ logDebug("Overlay capture output file created: " + photoFile.getAbsolutePath());
986
+ } catch (IOException error) {
987
+ logError("Overlay could not create capture output file", error);
988
+ pendingCapturePhotoFile = null;
989
+ captureInProgress.set(false);
990
+ finishWithError(SagepilotInAppCameraModule.ERROR_ENCODE_FAILED, "Could not create a temporary camera file.", error);
991
+ return;
992
+ }
993
+
994
+ capture.setTargetRotation(getTargetRotation());
995
+ ImageCapture.OutputFileOptions outputOptions = new ImageCapture.OutputFileOptions.Builder(photoFile)
996
+ .build();
997
+ capture.takePicture(outputOptions, cameraExecutor, new ImageCapture.OnImageSavedCallback() {
998
+ /** Handles a successful CameraX file save. */
999
+ @Override
1000
+ public void onImageSaved(ImageCapture.OutputFileResults outputFileResults) {
1001
+ logDebug("Overlay CameraX image saved: bytes=" + photoFile.length());
1002
+ activity.runOnUiThread(() -> showCapturedPreview(photoFile));
1003
+ }
1004
+
1005
+ /** Handles CameraX capture failures and removes the temporary file. */
1006
+ @Override
1007
+ public void onError(ImageCaptureException exception) {
1008
+ logError("Overlay CameraX capture failed", exception);
1009
+ deleteQuietly(photoFile);
1010
+ pendingCapturePhotoFile = null;
1011
+ captureInProgress.set(false);
1012
+ activity.runOnUiThread(() -> finishWithError(
1013
+ SagepilotInAppCameraModule.ERROR_CAMERA_UNAVAILABLE,
1014
+ "Could not capture a photo.",
1015
+ exception
1016
+ ));
1017
+ }
1018
+ });
1019
+ }
1020
+
1021
+ /** Shows the saved JPEG and waits for the customer to retake or confirm. */
1022
+ private void showCapturedPreview(File photoFile) {
1023
+ if (resultSettled.get()) {
1024
+ deleteQuietly(photoFile);
1025
+ return;
1026
+ }
1027
+
1028
+ logDebug("Overlay showing captured photo preview: " + photoFile.getAbsolutePath());
1029
+ pendingCapturePhotoFile = null;
1030
+ pendingPreviewPhotoFile = photoFile;
1031
+ showingPreview = true;
1032
+ captureInProgress.set(false);
1033
+ setProcessingOverlayVisible(false);
1034
+ if (capturedPreviewView != null) {
1035
+ capturedPreviewView.setImageURI(Uri.fromFile(photoFile));
1036
+ capturedPreviewView.setVisibility(View.VISIBLE);
1037
+ capturedPreviewView.animate()
1038
+ .alpha(1.0f)
1039
+ .setDuration(140L)
1040
+ .setInterpolator(controlInterpolator)
1041
+ .start();
1042
+ }
1043
+ if (previewView != null) {
1044
+ previewView.setVisibility(View.INVISIBLE);
1045
+ }
1046
+ if (cameraTitle != null) {
1047
+ cameraTitle.setText("Preview");
1048
+ }
1049
+ animatePreviewActions(true);
1050
+ updateControlState(true);
1051
+ }
1052
+
1053
+ /** Discards the current preview and returns to the live camera. */
1054
+ private void retakePhoto() {
1055
+ if (!showingPreview || resultSettled.get()) {
1056
+ return;
1057
+ }
1058
+
1059
+ logDebug("Overlay retaking captured photo");
1060
+ discardPendingPreviewFile();
1061
+ showingPreview = false;
1062
+ if (capturedPreviewView != null) {
1063
+ capturedPreviewView.animate()
1064
+ .alpha(0.0f)
1065
+ .setDuration(120L)
1066
+ .setInterpolator(controlInterpolator)
1067
+ .withEndAction(() -> {
1068
+ capturedPreviewView.setImageDrawable(null);
1069
+ capturedPreviewView.setVisibility(View.GONE);
1070
+ })
1071
+ .start();
1072
+ }
1073
+ if (previewView != null) {
1074
+ previewView.setVisibility(View.VISIBLE);
1075
+ }
1076
+ if (cameraTitle != null) {
1077
+ cameraTitle.setText("Camera");
1078
+ }
1079
+ animatePreviewActions(false);
1080
+ updateControlState(true);
1081
+ }
1082
+
1083
+ /** Confirms the previewed photo and runs the shared image-processing pipeline. */
1084
+ private void acceptPreviewPhoto() {
1085
+ File photoFile = pendingPreviewPhotoFile;
1086
+ if (!showingPreview || photoFile == null || resultSettled.get()) {
1087
+ return;
1088
+ }
1089
+
1090
+ logDebug("Overlay accepted captured photo preview");
1091
+ showingPreview = false;
1092
+ pendingPreviewPhotoFile = null;
1093
+ animatePreviewActions(false);
1094
+ updateControlState(false);
1095
+ setProcessingOverlayVisible(true);
1096
+ cameraExecutor.execute(() -> handleImageSaved(photoFile));
1097
+ }
1098
+
1099
+ /** Deletes and clears the currently previewed photo file. */
1100
+ private void discardPendingPreviewFile() {
1101
+ File photoFile = pendingPreviewPhotoFile;
1102
+ pendingPreviewPhotoFile = null;
1103
+ if (photoFile != null) {
1104
+ deleteQuietly(photoFile);
1105
+ }
1106
+ }
1107
+
1108
+ /** Deletes and clears the in-flight CameraX output file. */
1109
+ private void discardPendingCaptureFile() {
1110
+ File photoFile = pendingCapturePhotoFile;
1111
+ pendingCapturePhotoFile = null;
1112
+ if (photoFile != null) {
1113
+ deleteQuietly(photoFile);
1114
+ }
1115
+ }
1116
+
1117
+ /** Fades the preview action row in or out. */
1118
+ private void animatePreviewActions(boolean visible) {
1119
+ if (previewActionBar == null) {
1120
+ return;
1121
+ }
1122
+
1123
+ previewActionBar.animate().cancel();
1124
+ if (visible) {
1125
+ previewActionBar.setVisibility(View.VISIBLE);
1126
+ previewActionBar.animate()
1127
+ .alpha(1.0f)
1128
+ .setDuration(140L)
1129
+ .setInterpolator(controlInterpolator)
1130
+ .start();
1131
+ return;
1132
+ }
1133
+
1134
+ previewActionBar.animate()
1135
+ .alpha(0.0f)
1136
+ .setDuration(120L)
1137
+ .setInterpolator(controlInterpolator)
1138
+ .withEndAction(() -> previewActionBar.setVisibility(View.GONE))
1139
+ .start();
1140
+ }
1141
+
1142
+ /** Normalizes a captured JPEG and returns it through the overlay listener. */
1143
+ private void handleImageSaved(File photoFile) {
1144
+ File processedFile = null;
1145
+ try {
1146
+ logDebug("Overlay processing captured image: sourceBytes=" + photoFile.length());
1147
+ processedFile = createProcessedOutputFile();
1148
+ SagepilotInAppCameraImageProcessor.ProcessedImage processedImage =
1149
+ new SagepilotInAppCameraImageProcessor(imageMaxDimension, imageQuality, imageMaxFileSizeBytes)
1150
+ .process(photoFile, processedFile);
1151
+ String fileName = createResultFileName();
1152
+ logDebug(
1153
+ "Overlay image processed: resultFileName=" + fileName +
1154
+ ", processedBytes=" + processedImage.sizeBytes +
1155
+ ", base64Length=" + processedImage.dataBase64.length()
1156
+ );
1157
+ activity.runOnUiThread(() -> finishWithSuccess(fileName, processedImage.sizeBytes, processedImage.dataBase64));
1158
+ } catch (SagepilotInAppCameraImageProcessor.ImageTooLargeException error) {
1159
+ logWarn("Overlay processed image exceeded size cap", error);
1160
+ activity.runOnUiThread(() -> finishWithError(
1161
+ SagepilotInAppCameraModule.ERROR_FILE_TOO_LARGE,
1162
+ "Image is too large.",
1163
+ error
1164
+ ));
1165
+ } catch (IOException | RuntimeException error) {
1166
+ logError("Overlay image processing failed", error);
1167
+ activity.runOnUiThread(() -> finishWithError(
1168
+ SagepilotInAppCameraModule.ERROR_ENCODE_FAILED,
1169
+ "The photo could not be processed.",
1170
+ error
1171
+ ));
1172
+ } catch (OutOfMemoryError error) {
1173
+ logError("Overlay image processing OOM", error);
1174
+ activity.runOnUiThread(() -> finishWithError(
1175
+ SagepilotInAppCameraModule.ERROR_ENCODE_FAILED,
1176
+ "The photo is too large to process.",
1177
+ error
1178
+ ));
1179
+ } finally {
1180
+ deleteQuietly(photoFile);
1181
+ deleteQuietly(processedFile);
1182
+ captureInProgress.set(false);
1183
+ }
1184
+ }
1185
+
1186
+ /** Creates a temporary app-cache file for CameraX output. */
1187
+ private File createOutputFile() throws IOException {
1188
+ File directory = getCameraCacheDirectory();
1189
+ if (!directory.exists() && !directory.mkdirs()) {
1190
+ throw new IOException("Could not create camera cache directory.");
1191
+ }
1192
+ return File.createTempFile(PHOTO_FILE_PREFIX, PHOTO_FILE_EXTENSION, directory);
1193
+ }
1194
+
1195
+ /** Creates a temporary app-cache file for normalized JPEG output. */
1196
+ private File createProcessedOutputFile() throws IOException {
1197
+ File directory = getCameraCacheDirectory();
1198
+ if (!directory.exists() && !directory.mkdirs()) {
1199
+ throw new IOException("Could not create camera cache directory.");
1200
+ }
1201
+ return File.createTempFile(PROCESSED_PHOTO_FILE_PREFIX, PHOTO_FILE_EXTENSION, directory);
1202
+ }
1203
+
1204
+ /** Creates a stable customer-facing file name for the captured image. */
1205
+ private String createResultFileName() {
1206
+ return String.format(Locale.US, "sagepilot-photo-%d.jpg", System.currentTimeMillis());
1207
+ }
1208
+
1209
+ /** Deletes stale SDK camera cache files from prior interrupted overlay captures. */
1210
+ private void cleanupStaleCameraFiles() {
1211
+ File directory = getCameraCacheDirectory();
1212
+ File[] files = directory.listFiles();
1213
+ if (files == null) {
1214
+ return;
1215
+ }
1216
+ for (File file : files) {
1217
+ if (file.isFile() && file.getName().startsWith(PHOTO_FILE_PREFIX)) {
1218
+ deleteQuietly(file);
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ /** Returns the SDK camera cache directory. */
1224
+ private File getCameraCacheDirectory() {
1225
+ return new File(activity.getCacheDir(), CAMERA_CACHE_DIRECTORY_NAME);
1226
+ }
1227
+
1228
+ /** Deletes a temporary file without surfacing cleanup failures to the customer. */
1229
+ private void deleteQuietly(File file) {
1230
+ if (file != null && file.exists()) {
1231
+ file.delete();
1232
+ }
1233
+ }
1234
+
1235
+ /** Resolves the overlay with the picked-file payload expected by the NativeModule. */
1236
+ private void finishWithSuccess(String fileName, long size, String dataBase64) {
1237
+ if (!resultSettled.compareAndSet(false, true)) {
1238
+ return;
1239
+ }
1240
+ listener.onSuccess(fileName, IMAGE_MIME_TYPE, size, dataBase64);
1241
+ closeOverlay(false);
1242
+ }
1243
+
1244
+ /** Resolves the overlay as a customer cancellation. */
1245
+ private void finishWithCancel() {
1246
+ if (!resultSettled.compareAndSet(false, true)) {
1247
+ return;
1248
+ }
1249
+ logDebug("Overlay finishing with cancel");
1250
+ listener.onCancel();
1251
+ closeOverlay(true);
1252
+ }
1253
+
1254
+ /** Rejects the overlay with a stable SDK file-picker error code. */
1255
+ private void finishWithError(String code, String message, Throwable error) {
1256
+ if (!resultSettled.compareAndSet(false, true)) {
1257
+ return;
1258
+ }
1259
+
1260
+ logWarn("Overlay finishing with error: code=" + code + ", message=" + message, error);
1261
+ listener.onError(code, message, error);
1262
+ closeOverlay(true);
1263
+ }
1264
+
1265
+ /** Releases CameraX, removes the overlay view, and cleans up pending files. */
1266
+ private void closeOverlay(boolean discardPendingFiles) {
1267
+ if (backCallback != null) {
1268
+ backCallback.remove();
1269
+ backCallback = null;
1270
+ }
1271
+ if (cameraProvider != null) {
1272
+ cameraProvider.unbindAll();
1273
+ cameraProvider = null;
1274
+ }
1275
+ camera = null;
1276
+ imageCapture = null;
1277
+ if (cameraExecutor != null) {
1278
+ cameraExecutor.shutdownNow();
1279
+ cameraExecutor = null;
1280
+ }
1281
+ if (discardPendingFiles) {
1282
+ discardPendingCaptureFile();
1283
+ discardPendingPreviewFile();
1284
+ }
1285
+ if (rootView != null && rootView.getParent() instanceof ViewGroup) {
1286
+ ((ViewGroup) rootView.getParent()).removeView(rootView);
1287
+ }
1288
+ if (overlayDialog != null) {
1289
+ overlayDialog.setOnCancelListener(null);
1290
+ overlayDialog.dismiss();
1291
+ overlayDialog = null;
1292
+ }
1293
+ rootView = null;
1294
+ restoreWindowChrome();
1295
+ }
1296
+
1297
+ /** Updates enabled and visible states for capture controls. */
1298
+ private void updateControlState(boolean cameraReady) {
1299
+ boolean decisionAllowed = showingPreview && !captureInProgress.get() && !resultSettled.get();
1300
+ boolean captureAllowed = cameraReady && !showingPreview && !captureInProgress.get() && !resultSettled.get();
1301
+ boolean captureActive = captureInProgress.get() && !resultSettled.get();
1302
+ if (shutterButton != null) {
1303
+ shutterButton.setVisibility(showingPreview ? View.GONE : View.VISIBLE);
1304
+ shutterButton.setEnabled(captureAllowed);
1305
+ shutterButton.setAlpha(captureAllowed || captureActive ? 1.0f : 0.48f);
1306
+ }
1307
+ if (switchButton != null) {
1308
+ boolean switchAllowed = captureAllowed && canSwitchCamera();
1309
+ switchButton.setVisibility(!showingPreview && canSwitchCamera() ? View.VISIBLE : View.GONE);
1310
+ switchButton.setEnabled(switchAllowed);
1311
+ switchButton.setAlpha(switchAllowed ? 1.0f : 0.45f);
1312
+ }
1313
+ if (retakeButton != null) {
1314
+ retakeButton.setEnabled(decisionAllowed);
1315
+ retakeButton.setAlpha(decisionAllowed ? 1.0f : 0.55f);
1316
+ }
1317
+ if (usePhotoButton != null) {
1318
+ usePhotoButton.setEnabled(decisionAllowed);
1319
+ usePhotoButton.setAlpha(decisionAllowed ? 1.0f : 0.55f);
1320
+ }
1321
+ if (closeButton != null) {
1322
+ closeButton.setEnabled(!resultSettled.get());
1323
+ }
1324
+ }
1325
+
1326
+ /** Checks whether both front and back cameras are available for switching. */
1327
+ private boolean canSwitchCamera() {
1328
+ try {
1329
+ return cameraProvider != null &&
1330
+ hasCameraWithLens(CameraSelector.LENS_FACING_BACK) &&
1331
+ hasCameraWithLens(CameraSelector.LENS_FACING_FRONT);
1332
+ } catch (CameraInfoUnavailableException error) {
1333
+ return false;
1334
+ }
1335
+ }
1336
+
1337
+ /** Checks whether CameraX can provide a camera for a given lens facing value. */
1338
+ private boolean hasCameraWithLens(int nextLensFacing) throws CameraInfoUnavailableException {
1339
+ return cameraProvider != null && cameraProvider.hasCamera(createCameraSelector(nextLensFacing));
1340
+ }
1341
+
1342
+ /** Creates a CameraX selector for a lens facing value. */
1343
+ private CameraSelector createCameraSelector(int nextLensFacing) {
1344
+ return new CameraSelector.Builder().requireLensFacing(nextLensFacing).build();
1345
+ }
1346
+
1347
+ /** Creates a bounded capture selector so high-MP sensors do not emit full-sensor JPEGs. */
1348
+ private ResolutionSelector createCaptureResolutionSelector() {
1349
+ int boundWidth = Math.max(1, imageMaxDimension);
1350
+ int boundHeight = Math.max(1, Math.round(boundWidth * 0.75f));
1351
+ ResolutionStrategy resolutionStrategy = new ResolutionStrategy(
1352
+ new Size(boundWidth, boundHeight),
1353
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER
1354
+ );
1355
+ return new ResolutionSelector.Builder()
1356
+ .setResolutionStrategy(resolutionStrategy)
1357
+ .build();
1358
+ }
1359
+
1360
+ /** Reads the current display rotation for Preview and ImageCapture. */
1361
+ private int getTargetRotation() {
1362
+ if (previewView != null && previewView.getDisplay() != null) {
1363
+ return previewView.getDisplay().getRotation();
1364
+ }
1365
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && activity.getDisplay() != null) {
1366
+ return activity.getDisplay().getRotation();
1367
+ }
1368
+ return getLegacyDisplayRotation();
1369
+ }
1370
+
1371
+ /** Reads display rotation on older Android releases. */
1372
+ @SuppressWarnings("deprecation")
1373
+ private int getLegacyDisplayRotation() {
1374
+ if (activity.getWindowManager() != null && activity.getWindowManager().getDefaultDisplay() != null) {
1375
+ return activity.getWindowManager().getDefaultDisplay().getRotation();
1376
+ }
1377
+ return Surface.ROTATION_0;
1378
+ }
1379
+
1380
+ /** Converts density-independent pixels to raw pixels for programmatic layout. */
1381
+ private int dp(int value) {
1382
+ return Math.round(value * activity.getResources().getDisplayMetrics().density);
1383
+ }
1384
+
1385
+ /** Writes a native debug log and mirrors it into the React Native console. */
1386
+ private void logDebug(String message) {
1387
+ Log.d(TAG, message);
1388
+ SagepilotInAppCameraDebugEvents.debug(message);
1389
+ }
1390
+
1391
+ /** Writes a native warning log and mirrors it into the React Native console. */
1392
+ private void logWarn(String message) {
1393
+ Log.w(TAG, message);
1394
+ SagepilotInAppCameraDebugEvents.warn(message);
1395
+ }
1396
+
1397
+ /** Writes a native warning log with a cause and mirrors it into the React Native console. */
1398
+ private void logWarn(String message, Throwable error) {
1399
+ Log.w(TAG, message, error);
1400
+ SagepilotInAppCameraDebugEvents.warn(message);
1401
+ }
1402
+
1403
+ /** Writes a native error log and mirrors it into the React Native console. */
1404
+ private void logError(String message, Throwable error) {
1405
+ Log.e(TAG, message, error);
1406
+ SagepilotInAppCameraDebugEvents.error(message, error);
1407
+ }
1408
+
1409
+ interface Listener {
1410
+ /** Resolves a processed image produced by the in-process overlay. */
1411
+ void onSuccess(String fileName, String mimeType, long sizeBytes, String dataBase64);
1412
+
1413
+ /** Resolves a user cancellation from the in-process overlay. */
1414
+ void onCancel();
1415
+
1416
+ /** Rejects an in-process overlay failure. */
1417
+ void onError(String code, String message, Throwable error);
1418
+ }
1419
+ }