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