@capgo/camera-preview 7.3.11 → 7.4.0-alpha.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.
Files changed (47) hide show
  1. package/CapgoCameraPreview.podspec +16 -13
  2. package/README.md +492 -73
  3. package/android/build.gradle +11 -0
  4. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  5. package/android/src/main/AndroidManifest.xml +5 -3
  6. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +968 -505
  7. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +3017 -0
  8. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +119 -0
  9. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +63 -0
  10. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +79 -0
  11. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +167 -0
  12. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +40 -0
  13. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +35 -0
  14. package/dist/docs.json +1041 -161
  15. package/dist/esm/definitions.d.ts +484 -84
  16. package/dist/esm/definitions.js +10 -1
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/web.d.ts +78 -3
  19. package/dist/esm/web.js +813 -68
  20. package/dist/esm/web.js.map +1 -1
  21. package/dist/plugin.cjs.js +819 -68
  22. package/dist/plugin.cjs.js.map +1 -1
  23. package/dist/plugin.js +819 -68
  24. package/dist/plugin.js.map +1 -1
  25. package/ios/Sources/CapgoCameraPreviewPlugin/CameraController.swift +1663 -0
  26. package/ios/Sources/CapgoCameraPreviewPlugin/GridOverlayView.swift +65 -0
  27. package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +1550 -0
  28. package/ios/Tests/CameraPreviewPluginTests/CameraPreviewPluginTests.swift +15 -0
  29. package/package.json +2 -2
  30. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraActivity.java +0 -1279
  31. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomSurfaceView.java +0 -29
  32. package/android/src/main/java/com/ahm/capacitor/camera/preview/CustomTextureView.java +0 -39
  33. package/android/src/main/java/com/ahm/capacitor/camera/preview/Preview.java +0 -461
  34. package/android/src/main/java/com/ahm/capacitor/camera/preview/TapGestureDetector.java +0 -24
  35. package/ios/Plugin/CameraController.swift +0 -809
  36. package/ios/Plugin/Info.plist +0 -24
  37. package/ios/Plugin/Plugin.h +0 -10
  38. package/ios/Plugin/Plugin.m +0 -18
  39. package/ios/Plugin/Plugin.swift +0 -511
  40. package/ios/Plugin.xcodeproj/project.pbxproj +0 -593
  41. package/ios/Plugin.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  42. package/ios/Plugin.xcworkspace/contents.xcworkspacedata +0 -10
  43. package/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  44. package/ios/PluginTests/Info.plist +0 -22
  45. package/ios/PluginTests/PluginTests.swift +0 -83
  46. package/ios/Podfile +0 -13
  47. package/ios/Podfile.lock +0 -23
@@ -0,0 +1,3017 @@
1
+ package com.ahm.capacitor.camera.preview;
2
+
3
+ import android.content.Context;
4
+ import android.graphics.Bitmap;
5
+ import android.graphics.BitmapFactory;
6
+ import android.graphics.Color;
7
+ import android.graphics.Rect;
8
+ import android.graphics.drawable.GradientDrawable;
9
+ import android.hardware.camera2.CameraAccessException;
10
+ import android.hardware.camera2.CameraCharacteristics;
11
+ import android.hardware.camera2.CameraManager;
12
+ import android.location.Location;
13
+ import android.media.MediaScannerConnection;
14
+ import android.os.Build;
15
+ import android.os.Environment;
16
+ import android.util.Base64;
17
+ import android.util.DisplayMetrics;
18
+ import android.util.Log;
19
+ import android.util.Size;
20
+ import android.view.MotionEvent;
21
+ import android.view.View;
22
+ import android.view.ViewGroup;
23
+ import android.view.animation.AlphaAnimation;
24
+ import android.view.animation.Animation;
25
+ import android.view.animation.AnimationSet;
26
+ import android.view.animation.AnimationUtils;
27
+ import android.view.animation.ScaleAnimation;
28
+ import android.webkit.WebView;
29
+ import android.webkit.WebView;
30
+ import android.widget.FrameLayout;
31
+ import android.widget.FrameLayout;
32
+ import androidx.annotation.NonNull;
33
+ import androidx.annotation.OptIn;
34
+ import androidx.camera.camera2.interop.Camera2CameraInfo;
35
+ import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
36
+ import androidx.camera.core.AspectRatio;
37
+ import androidx.camera.core.Camera;
38
+ import androidx.camera.core.CameraInfo;
39
+ import androidx.camera.core.CameraSelector;
40
+ import androidx.camera.core.FocusMeteringAction;
41
+ import androidx.camera.core.FocusMeteringResult;
42
+ import androidx.camera.core.ImageCapture;
43
+ import androidx.camera.core.ImageCaptureException;
44
+ import androidx.camera.core.ImageProxy;
45
+ import androidx.camera.core.MeteringPoint;
46
+ import androidx.camera.core.MeteringPointFactory;
47
+ import androidx.camera.core.Preview;
48
+ import androidx.camera.core.ResolutionInfo;
49
+ import androidx.camera.core.ZoomState;
50
+ import androidx.camera.core.resolutionselector.AspectRatioStrategy;
51
+ import androidx.camera.core.resolutionselector.ResolutionSelector;
52
+ import androidx.camera.core.resolutionselector.ResolutionStrategy;
53
+ import androidx.camera.lifecycle.ProcessCameraProvider;
54
+ import androidx.camera.view.PreviewView;
55
+ import androidx.core.content.ContextCompat;
56
+ import androidx.exifinterface.media.ExifInterface;
57
+ import androidx.lifecycle.Lifecycle;
58
+ import androidx.lifecycle.LifecycleObserver;
59
+ import androidx.lifecycle.LifecycleOwner;
60
+ import androidx.lifecycle.LifecycleRegistry;
61
+ import com.ahm.capacitor.camera.preview.model.CameraSessionConfiguration;
62
+ import com.ahm.capacitor.camera.preview.model.LensInfo;
63
+ import com.ahm.capacitor.camera.preview.model.ZoomFactors;
64
+ import com.google.common.util.concurrent.ListenableFuture;
65
+ import java.io.ByteArrayOutputStream;
66
+ import java.io.File;
67
+ import java.io.FileOutputStream;
68
+ import java.io.IOException;
69
+ import java.nio.ByteBuffer;
70
+ import java.text.SimpleDateFormat;
71
+ import java.util.ArrayList;
72
+ import java.util.Arrays;
73
+ import java.util.Collections;
74
+ import java.util.List;
75
+ import java.util.Locale;
76
+ import java.util.Objects;
77
+ import java.util.Set;
78
+ import java.util.concurrent.Executor;
79
+ import java.util.concurrent.ExecutorService;
80
+ import java.util.concurrent.Executors;
81
+ import java.util.concurrent.TimeUnit;
82
+ import org.json.JSONObject;
83
+
84
+ public class CameraXView implements LifecycleOwner, LifecycleObserver {
85
+
86
+ private static final String TAG = "CameraPreview CameraXView";
87
+
88
+ public interface CameraXViewListener {
89
+ void onPictureTaken(String base64, JSONObject exif);
90
+ void onPictureTakenError(String message);
91
+ void onSampleTaken(String result);
92
+ void onSampleTakenError(String message);
93
+ void onCameraStarted(int width, int height, int x, int y);
94
+ void onCameraStartError(String message);
95
+ }
96
+
97
+ private ProcessCameraProvider cameraProvider;
98
+ private Camera camera;
99
+ private ImageCapture imageCapture;
100
+ private ImageCapture sampleImageCapture;
101
+ private PreviewView previewView;
102
+ private GridOverlayView gridOverlayView;
103
+ private FrameLayout previewContainer;
104
+ private View focusIndicatorView;
105
+ private CameraSelector currentCameraSelector;
106
+ private String currentDeviceId;
107
+ private int currentFlashMode = ImageCapture.FLASH_MODE_OFF;
108
+ private CameraSessionConfiguration sessionConfig;
109
+ private CameraXViewListener listener;
110
+ private final Context context;
111
+ private final WebView webView;
112
+ private final LifecycleRegistry lifecycleRegistry;
113
+ private final Executor mainExecutor;
114
+ private ExecutorService cameraExecutor;
115
+ private boolean isRunning = false;
116
+ private Size currentPreviewResolution = null;
117
+ private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
118
+
119
+ public CameraXView(Context context, WebView webView) {
120
+ this.context = context;
121
+ this.webView = webView;
122
+ this.lifecycleRegistry = new LifecycleRegistry(this);
123
+ this.mainExecutor = ContextCompat.getMainExecutor(context);
124
+
125
+ mainExecutor.execute(() ->
126
+ lifecycleRegistry.setCurrentState(Lifecycle.State.CREATED)
127
+ );
128
+ }
129
+
130
+ @NonNull
131
+ @Override
132
+ public Lifecycle getLifecycle() {
133
+ return lifecycleRegistry;
134
+ }
135
+
136
+ public void setListener(CameraXViewListener listener) {
137
+ this.listener = listener;
138
+ }
139
+
140
+ public boolean isRunning() {
141
+ return isRunning;
142
+ }
143
+
144
+ private void saveImageToGallery(byte[] data) {
145
+ try {
146
+ // Detect image format from byte array header
147
+ String extension = ".jpg";
148
+ String mimeType = "image/jpeg";
149
+
150
+ if (data.length >= 8) {
151
+ // Check for PNG signature (89 50 4E 47 0D 0A 1A 0A)
152
+ if (
153
+ data[0] == (byte) 0x89 &&
154
+ data[1] == 0x50 &&
155
+ data[2] == 0x4E &&
156
+ data[3] == 0x47
157
+ ) {
158
+ extension = ".png";
159
+ mimeType = "image/png";
160
+ }
161
+ // Check for JPEG signature (FF D8 FF)
162
+ else if (
163
+ data[0] == (byte) 0xFF &&
164
+ data[1] == (byte) 0xD8 &&
165
+ data[2] == (byte) 0xFF
166
+ ) {
167
+ extension = ".jpg";
168
+ mimeType = "image/jpeg";
169
+ }
170
+ // Check for WebP signature (RIFF ... WEBP)
171
+ else if (
172
+ data[0] == 0x52 &&
173
+ data[1] == 0x49 &&
174
+ data[2] == 0x46 &&
175
+ data[3] == 0x46 &&
176
+ data.length >= 12 &&
177
+ data[8] == 0x57 &&
178
+ data[9] == 0x45 &&
179
+ data[10] == 0x42 &&
180
+ data[11] == 0x50
181
+ ) {
182
+ extension = ".webp";
183
+ mimeType = "image/webp";
184
+ }
185
+ }
186
+
187
+ File photo = new File(
188
+ Environment.getExternalStoragePublicDirectory(
189
+ Environment.DIRECTORY_PICTURES
190
+ ),
191
+ "IMG_" +
192
+ new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
193
+ new java.util.Date()
194
+ ) +
195
+ extension
196
+ );
197
+ FileOutputStream fos = new FileOutputStream(photo);
198
+ fos.write(data);
199
+ fos.close();
200
+
201
+ // Notify the gallery of the new image
202
+ MediaScannerConnection.scanFile(
203
+ this.context,
204
+ new String[] { photo.getAbsolutePath() },
205
+ new String[] { mimeType },
206
+ null
207
+ );
208
+ } catch (IOException e) {
209
+ Log.e(TAG, "Error saving image to gallery", e);
210
+ }
211
+ }
212
+
213
+ public void startSession(CameraSessionConfiguration config) {
214
+ this.sessionConfig = config;
215
+ cameraExecutor = Executors.newSingleThreadExecutor();
216
+ mainExecutor.execute(() -> {
217
+ lifecycleRegistry.setCurrentState(Lifecycle.State.STARTED);
218
+ setupCamera();
219
+ });
220
+ }
221
+
222
+ public void stopSession() {
223
+ isRunning = false;
224
+ // Cancel any ongoing focus operation when stopping session
225
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
226
+ currentFocusFuture.cancel(true);
227
+ }
228
+ currentFocusFuture = null;
229
+
230
+ mainExecutor.execute(() -> {
231
+ if (cameraProvider != null) {
232
+ cameraProvider.unbindAll();
233
+ }
234
+ lifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED);
235
+ if (cameraExecutor != null) {
236
+ cameraExecutor.shutdownNow();
237
+ }
238
+ removePreviewView();
239
+ });
240
+ }
241
+
242
+ private void setupCamera() {
243
+ ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
244
+ ProcessCameraProvider.getInstance(context);
245
+ cameraProviderFuture.addListener(
246
+ () -> {
247
+ try {
248
+ cameraProvider = cameraProviderFuture.get();
249
+ setupPreviewView();
250
+ bindCameraUseCases();
251
+ } catch (Exception e) {
252
+ if (listener != null) {
253
+ listener.onCameraStartError(
254
+ "Error initializing camera: " + e.getMessage()
255
+ );
256
+ }
257
+ }
258
+ },
259
+ mainExecutor
260
+ );
261
+ }
262
+
263
+ private void setupPreviewView() {
264
+ if (previewView != null) {
265
+ removePreviewView();
266
+ }
267
+ if (sessionConfig.isToBack()) {
268
+ webView.setBackgroundColor(android.graphics.Color.TRANSPARENT);
269
+ }
270
+
271
+ // Create a container to hold both the preview and grid overlay
272
+ previewContainer = new FrameLayout(context);
273
+ // Ensure container can receive touch events
274
+ previewContainer.setClickable(true);
275
+ previewContainer.setFocusable(true);
276
+
277
+ // Create and setup the preview view
278
+ previewView = new PreviewView(context);
279
+ previewView.setScaleType(PreviewView.ScaleType.FIT_CENTER);
280
+ // Also make preview view touchable as backup
281
+ previewView.setClickable(true);
282
+ previewView.setFocusable(true);
283
+
284
+ // Add touch listener to both container and preview view for maximum compatibility
285
+ View.OnTouchListener touchListener = new View.OnTouchListener() {
286
+ @Override
287
+ public boolean onTouch(View v, MotionEvent event) {
288
+ Log.d(
289
+ TAG,
290
+ "onTouch: " +
291
+ v.getClass().getSimpleName() +
292
+ " received touch event: " +
293
+ event.getAction() +
294
+ " at (" +
295
+ event.getX() +
296
+ ", " +
297
+ event.getY() +
298
+ ")"
299
+ );
300
+
301
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
302
+ float x = event.getX() / v.getWidth();
303
+ float y = event.getY() / v.getHeight();
304
+
305
+ Log.d(
306
+ TAG,
307
+ "onTouch: Touch detected at raw coords (" +
308
+ event.getX() +
309
+ ", " +
310
+ event.getY() +
311
+ "), view size: " +
312
+ v.getWidth() +
313
+ "x" +
314
+ v.getHeight() +
315
+ ", normalized: (" +
316
+ x +
317
+ ", " +
318
+ y +
319
+ ")"
320
+ );
321
+
322
+ try {
323
+ // Trigger focus with indicator
324
+ setFocus(x, y);
325
+ } catch (Exception e) {
326
+ Log.e(TAG, "Error during tap-to-focus: " + e.getMessage(), e);
327
+ }
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+ };
333
+
334
+ previewContainer.setOnTouchListener(touchListener);
335
+ previewView.setOnTouchListener(touchListener);
336
+
337
+ previewContainer.addView(
338
+ previewView,
339
+ new FrameLayout.LayoutParams(
340
+ FrameLayout.LayoutParams.MATCH_PARENT,
341
+ FrameLayout.LayoutParams.MATCH_PARENT
342
+ )
343
+ );
344
+
345
+ // Create and setup the grid overlay
346
+ gridOverlayView = new GridOverlayView(context);
347
+ // Make grid overlay not intercept touch events
348
+ gridOverlayView.setClickable(false);
349
+ gridOverlayView.setFocusable(false);
350
+ previewContainer.addView(
351
+ gridOverlayView,
352
+ new FrameLayout.LayoutParams(
353
+ FrameLayout.LayoutParams.MATCH_PARENT,
354
+ FrameLayout.LayoutParams.MATCH_PARENT
355
+ )
356
+ );
357
+ // Set grid mode after adding to container to ensure proper layout
358
+ gridOverlayView.post(() -> {
359
+ String currentGridMode = sessionConfig.getGridMode();
360
+ Log.d(TAG, "setupPreviewView: Setting grid mode to: " + currentGridMode);
361
+ gridOverlayView.setGridMode(currentGridMode);
362
+ });
363
+
364
+ // Add a layout listener to update grid bounds when preview view changes size
365
+ previewView.addOnLayoutChangeListener(
366
+ (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
367
+ if (
368
+ left != oldLeft ||
369
+ top != oldTop ||
370
+ right != oldRight ||
371
+ bottom != oldBottom
372
+ ) {
373
+ Log.d(TAG, "PreviewView layout changed, updating grid bounds");
374
+ updateGridOverlayBounds();
375
+ }
376
+ }
377
+ );
378
+
379
+ ViewGroup parent = (ViewGroup) webView.getParent();
380
+ if (parent != null) {
381
+ FrameLayout.LayoutParams layoutParams = calculatePreviewLayoutParams();
382
+ parent.addView(previewContainer, layoutParams);
383
+ if (sessionConfig.isToBack()) webView.bringToFront();
384
+
385
+ // Log the actual position after layout
386
+ previewContainer.post(() -> {
387
+ Log.d(TAG, "========================");
388
+ Log.d(TAG, "ACTUAL CAMERA VIEW POSITION (after layout):");
389
+ Log.d(
390
+ TAG,
391
+ "Container position - Left: " +
392
+ previewContainer.getLeft() +
393
+ ", Top: " +
394
+ previewContainer.getTop() +
395
+ ", Right: " +
396
+ previewContainer.getRight() +
397
+ ", Bottom: " +
398
+ previewContainer.getBottom()
399
+ );
400
+ Log.d(
401
+ TAG,
402
+ "Container size - Width: " +
403
+ previewContainer.getWidth() +
404
+ ", Height: " +
405
+ previewContainer.getHeight()
406
+ );
407
+
408
+ // Get parent info
409
+ ViewGroup containerParent = (ViewGroup) previewContainer.getParent();
410
+ if (containerParent != null) {
411
+ Log.d(
412
+ TAG,
413
+ "Parent class: " + containerParent.getClass().getSimpleName()
414
+ );
415
+ Log.d(
416
+ TAG,
417
+ "Parent size - Width: " +
418
+ containerParent.getWidth() +
419
+ ", Height: " +
420
+ containerParent.getHeight()
421
+ );
422
+ }
423
+ Log.d(TAG, "========================");
424
+ });
425
+ }
426
+ }
427
+
428
+ private FrameLayout.LayoutParams calculatePreviewLayoutParams() {
429
+ // sessionConfig already contains pixel-converted coordinates with webview offsets applied
430
+ int x = sessionConfig.getX();
431
+ int y = sessionConfig.getY();
432
+ int width = sessionConfig.getWidth();
433
+ int height = sessionConfig.getHeight();
434
+ String aspectRatio = sessionConfig.getAspectRatio();
435
+
436
+ Log.d(
437
+ TAG,
438
+ "calculatePreviewLayoutParams: Using sessionConfig values - x:" +
439
+ x +
440
+ " y:" +
441
+ y +
442
+ " width:" +
443
+ width +
444
+ " height:" +
445
+ height +
446
+ " aspectRatio:" +
447
+ aspectRatio
448
+ );
449
+
450
+ // Apply aspect ratio if specified and no explicit size was given
451
+ if (aspectRatio != null && !aspectRatio.isEmpty()) {
452
+ String[] ratios = aspectRatio.split(":");
453
+ if (ratios.length == 2) {
454
+ try {
455
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
456
+ float ratio =
457
+ Float.parseFloat(ratios[1]) / Float.parseFloat(ratios[0]);
458
+
459
+ // Calculate optimal size while maintaining aspect ratio
460
+ int optimalWidth = width;
461
+ int optimalHeight = (int) (width / ratio);
462
+
463
+ if (optimalHeight > height) {
464
+ // Height constraint is tighter, fit by height
465
+ optimalHeight = height;
466
+ optimalWidth = (int) (height * ratio);
467
+ }
468
+
469
+ // Store the old dimensions to check if we need to recenter
470
+ int oldWidth = width;
471
+ int oldHeight = height;
472
+ width = optimalWidth;
473
+ height = optimalHeight;
474
+
475
+ // If we're centered and dimensions changed, recalculate position
476
+ if (sessionConfig.isCentered()) {
477
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
478
+
479
+ if (width != oldWidth) {
480
+ int screenWidth = metrics.widthPixels;
481
+ x = (screenWidth - width) / 2;
482
+ Log.d(
483
+ TAG,
484
+ "calculatePreviewLayoutParams: Recentered X after aspect ratio - " +
485
+ "oldWidth=" +
486
+ oldWidth +
487
+ ", newWidth=" +
488
+ width +
489
+ ", screenWidth=" +
490
+ screenWidth +
491
+ ", newX=" +
492
+ x
493
+ );
494
+ }
495
+
496
+ if (height != oldHeight) {
497
+ int screenHeight = metrics.heightPixels;
498
+ // Always center based on full screen height
499
+ y = (screenHeight - height) / 2;
500
+ Log.d(
501
+ TAG,
502
+ "calculatePreviewLayoutParams: Recentered Y after aspect ratio - " +
503
+ "oldHeight=" +
504
+ oldHeight +
505
+ ", newHeight=" +
506
+ height +
507
+ ", screenHeight=" +
508
+ screenHeight +
509
+ ", newY=" +
510
+ y
511
+ );
512
+ }
513
+ }
514
+
515
+ Log.d(
516
+ TAG,
517
+ "calculatePreviewLayoutParams: Applied aspect ratio " +
518
+ aspectRatio +
519
+ " - new size: " +
520
+ width +
521
+ "x" +
522
+ height
523
+ );
524
+ } catch (NumberFormatException e) {
525
+ Log.e(TAG, "Invalid aspect ratio format: " + aspectRatio, e);
526
+ }
527
+ }
528
+ }
529
+
530
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
531
+ width,
532
+ height
533
+ );
534
+
535
+ // The X and Y positions passed from CameraPreview already include webView insets
536
+ // when edge-to-edge is active, so we don't need to add them again here
537
+ layoutParams.leftMargin = x;
538
+ layoutParams.topMargin = y;
539
+
540
+ Log.d(
541
+ TAG,
542
+ "calculatePreviewLayoutParams: Position calculation - x:" +
543
+ x +
544
+ " (leftMargin=" +
545
+ layoutParams.leftMargin +
546
+ "), y:" +
547
+ y +
548
+ " (topMargin=" +
549
+ layoutParams.topMargin +
550
+ ")"
551
+ );
552
+
553
+ Log.d(
554
+ TAG,
555
+ "calculatePreviewLayoutParams: Final layout - x:" +
556
+ x +
557
+ " y:" +
558
+ y +
559
+ " width:" +
560
+ width +
561
+ " height:" +
562
+ height
563
+ );
564
+ return layoutParams;
565
+ }
566
+
567
+ private void removePreviewView() {
568
+ if (previewContainer != null) {
569
+ ViewGroup parent = (ViewGroup) previewContainer.getParent();
570
+ if (parent != null) {
571
+ parent.removeView(previewContainer);
572
+ }
573
+ previewContainer = null;
574
+ }
575
+ if (previewView != null) {
576
+ previewView = null;
577
+ }
578
+ if (gridOverlayView != null) {
579
+ gridOverlayView = null;
580
+ }
581
+ if (focusIndicatorView != null) {
582
+ focusIndicatorView = null;
583
+ }
584
+ webView.setBackgroundColor(android.graphics.Color.WHITE);
585
+ }
586
+
587
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
588
+ private void bindCameraUseCases() {
589
+ if (cameraProvider == null) return;
590
+ mainExecutor.execute(() -> {
591
+ try {
592
+ Log.d(
593
+ TAG,
594
+ "Building camera selector with deviceId: " +
595
+ sessionConfig.getDeviceId() +
596
+ " and position: " +
597
+ sessionConfig.getPosition()
598
+ );
599
+ currentCameraSelector = buildCameraSelector();
600
+
601
+ ResolutionSelector.Builder resolutionSelectorBuilder =
602
+ new ResolutionSelector.Builder()
603
+ .setResolutionStrategy(
604
+ ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY
605
+ );
606
+
607
+ if (sessionConfig.getAspectRatio() != null) {
608
+ int aspectRatio;
609
+ if ("16:9".equals(sessionConfig.getAspectRatio())) {
610
+ aspectRatio = AspectRatio.RATIO_16_9;
611
+ } else { // "4:3"
612
+ aspectRatio = AspectRatio.RATIO_4_3;
613
+ }
614
+ resolutionSelectorBuilder.setAspectRatioStrategy(
615
+ new AspectRatioStrategy(
616
+ aspectRatio,
617
+ AspectRatioStrategy.FALLBACK_RULE_AUTO
618
+ )
619
+ );
620
+ }
621
+
622
+ ResolutionSelector resolutionSelector =
623
+ resolutionSelectorBuilder.build();
624
+
625
+ Preview preview = new Preview.Builder()
626
+ .setResolutionSelector(resolutionSelector)
627
+ .build();
628
+ imageCapture = new ImageCapture.Builder()
629
+ .setResolutionSelector(resolutionSelector)
630
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
631
+ .setFlashMode(currentFlashMode)
632
+ .build();
633
+ sampleImageCapture = imageCapture;
634
+ preview.setSurfaceProvider(previewView.getSurfaceProvider());
635
+ // Unbind any existing use cases and bind new ones
636
+ cameraProvider.unbindAll();
637
+ camera = cameraProvider.bindToLifecycle(
638
+ this,
639
+ currentCameraSelector,
640
+ preview,
641
+ imageCapture
642
+ );
643
+
644
+ // Log details about the active camera
645
+ Log.d(TAG, "Use cases bound. Inspecting active camera and use cases.");
646
+ CameraInfo cameraInfo = camera.getCameraInfo();
647
+ Log.d(
648
+ TAG,
649
+ "Bound Camera ID: " + Camera2CameraInfo.from(cameraInfo).getCameraId()
650
+ );
651
+
652
+ // Log zoom state
653
+ ZoomState zoomState = cameraInfo.getZoomState().getValue();
654
+ if (zoomState != null) {
655
+ Log.d(
656
+ TAG,
657
+ "Active Zoom State: " +
658
+ "min=" +
659
+ zoomState.getMinZoomRatio() +
660
+ ", " +
661
+ "max=" +
662
+ zoomState.getMaxZoomRatio() +
663
+ ", " +
664
+ "current=" +
665
+ zoomState.getZoomRatio()
666
+ );
667
+ }
668
+
669
+ // Log physical cameras of the active camera
670
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
671
+ Set<CameraInfo> physicalCameras = cameraInfo.getPhysicalCameraInfos();
672
+ Log.d(
673
+ TAG,
674
+ "Active camera has " + physicalCameras.size() + " physical cameras."
675
+ );
676
+ for (CameraInfo physical : physicalCameras) {
677
+ Log.d(
678
+ TAG,
679
+ " - Physical camera ID: " +
680
+ Camera2CameraInfo.from(physical).getCameraId()
681
+ );
682
+ }
683
+ }
684
+
685
+ // Log resolution info
686
+ ResolutionInfo previewResolution = preview.getResolutionInfo();
687
+ if (previewResolution != null) {
688
+ currentPreviewResolution = previewResolution.getResolution();
689
+ Log.d(TAG, "Preview resolution: " + currentPreviewResolution);
690
+ }
691
+ ResolutionInfo imageCaptureResolution =
692
+ imageCapture.getResolutionInfo();
693
+ if (imageCaptureResolution != null) {
694
+ Log.d(
695
+ TAG,
696
+ "Image capture resolution: " +
697
+ imageCaptureResolution.getResolution()
698
+ );
699
+ }
700
+
701
+ // Set initial zoom if specified, prioritizing targetZoom over default zoomFactor
702
+ float initialZoom = sessionConfig.getTargetZoom() != 1.0f
703
+ ? sessionConfig.getTargetZoom()
704
+ : sessionConfig.getZoomFactor();
705
+ if (initialZoom != 1.0f) {
706
+ Log.d(TAG, "Applying initial zoom of " + initialZoom);
707
+
708
+ // Validate zoom is within bounds
709
+ if (zoomState != null) {
710
+ float minZoom = zoomState.getMinZoomRatio();
711
+ float maxZoom = zoomState.getMaxZoomRatio();
712
+
713
+ if (initialZoom < minZoom || initialZoom > maxZoom) {
714
+ if (listener != null) {
715
+ listener.onCameraStartError(
716
+ "Initial zoom level " +
717
+ initialZoom +
718
+ " is not available. " +
719
+ "Valid range is " +
720
+ minZoom +
721
+ " to " +
722
+ maxZoom
723
+ );
724
+ return;
725
+ }
726
+ }
727
+ }
728
+
729
+ setZoomInternal(initialZoom);
730
+ }
731
+
732
+ isRunning = true;
733
+ Log.d(TAG, "bindCameraUseCases: Camera bound successfully");
734
+ if (listener != null) {
735
+ // Post the callback to ensure layout is complete
736
+ previewContainer.post(() -> {
737
+ // Return actual preview container dimensions instead of requested dimensions
738
+ // Get the actual camera dimensions and position
739
+ int actualWidth = getPreviewWidth();
740
+ int actualHeight = getPreviewHeight();
741
+ int actualX = getPreviewX();
742
+ int actualY = getPreviewY();
743
+
744
+ Log.d(
745
+ TAG,
746
+ "onCameraStarted callback - actualX=" +
747
+ actualX +
748
+ ", actualY=" +
749
+ actualY +
750
+ ", actualWidth=" +
751
+ actualWidth +
752
+ ", actualHeight=" +
753
+ actualHeight
754
+ );
755
+
756
+ // Update grid overlay bounds after camera is started
757
+ updateGridOverlayBounds();
758
+
759
+ listener.onCameraStarted(
760
+ actualWidth,
761
+ actualHeight,
762
+ actualX,
763
+ actualY
764
+ );
765
+ });
766
+ }
767
+ } catch (Exception e) {
768
+ if (listener != null) listener.onCameraStartError(
769
+ "Error binding camera: " + e.getMessage()
770
+ );
771
+ }
772
+ });
773
+ }
774
+
775
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
776
+ private CameraSelector buildCameraSelector() {
777
+ CameraSelector.Builder builder = new CameraSelector.Builder();
778
+ final String deviceId = sessionConfig.getDeviceId();
779
+
780
+ if (deviceId != null && !deviceId.isEmpty()) {
781
+ builder.addCameraFilter(cameraInfos -> {
782
+ for (CameraInfo cameraInfo : cameraInfos) {
783
+ if (
784
+ deviceId.equals(Camera2CameraInfo.from(cameraInfo).getCameraId())
785
+ ) {
786
+ return Collections.singletonList(cameraInfo);
787
+ }
788
+ }
789
+ return Collections.emptyList();
790
+ });
791
+ } else {
792
+ String position = sessionConfig.getPosition();
793
+ int requiredFacing = "front".equals(position)
794
+ ? CameraSelector.LENS_FACING_FRONT
795
+ : CameraSelector.LENS_FACING_BACK;
796
+ builder.requireLensFacing(requiredFacing);
797
+ }
798
+ return builder.build();
799
+ }
800
+
801
+ private static String getCameraId(
802
+ androidx.camera.core.CameraInfo cameraInfo
803
+ ) {
804
+ try {
805
+ // Generate a stable ID based on camera characteristics
806
+ boolean isBack = isBackCamera(cameraInfo);
807
+ float minZoom = Objects.requireNonNull(
808
+ cameraInfo.getZoomState().getValue()
809
+ ).getMinZoomRatio();
810
+ float maxZoom = cameraInfo.getZoomState().getValue().getMaxZoomRatio();
811
+
812
+ // Create a unique ID based on camera properties
813
+ String position = isBack ? "back" : "front";
814
+ return position + "_" + minZoom + "_" + maxZoom;
815
+ } catch (Exception e) {
816
+ return "unknown_camera";
817
+ }
818
+ }
819
+
820
+ private static boolean isBackCamera(
821
+ androidx.camera.core.CameraInfo cameraInfo
822
+ ) {
823
+ try {
824
+ // Check if this camera matches the back camera selector
825
+ CameraSelector backSelector = new CameraSelector.Builder()
826
+ .requireLensFacing(CameraSelector.LENS_FACING_BACK)
827
+ .build();
828
+
829
+ // Try to filter cameras with back selector - if this camera is included, it's a back camera
830
+ List<androidx.camera.core.CameraInfo> backCameras = backSelector.filter(
831
+ Collections.singletonList(cameraInfo)
832
+ );
833
+ return !backCameras.isEmpty();
834
+ } catch (Exception e) {
835
+ Log.w(TAG, "Error determining camera direction, assuming back camera", e);
836
+ return true; // Default to back camera
837
+ }
838
+ }
839
+
840
+ public void capturePhoto(
841
+ int quality,
842
+ final boolean saveToGallery,
843
+ Integer width,
844
+ Integer height,
845
+ String aspectRatio,
846
+ Location location
847
+ ) {
848
+ Log.d(
849
+ TAG,
850
+ "capturePhoto: Starting photo capture with quality: " +
851
+ quality +
852
+ ", width: " +
853
+ width +
854
+ ", height: " +
855
+ height +
856
+ ", aspectRatio: " +
857
+ aspectRatio
858
+ );
859
+
860
+ // Check for conflicting parameters
861
+ if (aspectRatio != null && (width != null || height != null)) {
862
+ if (listener != null) {
863
+ listener.onPictureTakenError(
864
+ "Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start."
865
+ );
866
+ }
867
+ return;
868
+ }
869
+
870
+ if (imageCapture == null) {
871
+ if (listener != null) {
872
+ listener.onPictureTakenError("Camera not ready");
873
+ }
874
+ return;
875
+ }
876
+
877
+ File tempFile = new File(context.getCacheDir(), "temp_image.jpg");
878
+ ImageCapture.OutputFileOptions outputFileOptions =
879
+ new ImageCapture.OutputFileOptions.Builder(tempFile).build();
880
+
881
+ imageCapture.takePicture(
882
+ outputFileOptions,
883
+ cameraExecutor,
884
+ new ImageCapture.OnImageSavedCallback() {
885
+ @Override
886
+ public void onError(@NonNull ImageCaptureException exception) {
887
+ Log.e(TAG, "capturePhoto: Photo capture failed", exception);
888
+ if (listener != null) {
889
+ listener.onPictureTakenError(
890
+ "Photo capture failed: " + exception.getMessage()
891
+ );
892
+ }
893
+ }
894
+
895
+ @Override
896
+ public void onImageSaved(
897
+ @NonNull ImageCapture.OutputFileResults output
898
+ ) {
899
+ try {
900
+ // Read file using FileInputStream for compatibility
901
+ byte[] bytes = new byte[(int) tempFile.length()];
902
+ java.io.FileInputStream fis = new java.io.FileInputStream(tempFile);
903
+ fis.read(bytes);
904
+ fis.close();
905
+
906
+ ExifInterface exifInterface = new ExifInterface(
907
+ tempFile.getAbsolutePath()
908
+ );
909
+
910
+ if (location != null) {
911
+ exifInterface.setGpsInfo(location);
912
+ }
913
+
914
+ JSONObject exifData = getExifData(exifInterface);
915
+
916
+ // Use the stored aspectRatio if none is provided and no width/height is specified
917
+ String captureAspectRatio = aspectRatio;
918
+ if (
919
+ width == null &&
920
+ height == null &&
921
+ aspectRatio == null &&
922
+ sessionConfig != null
923
+ ) {
924
+ captureAspectRatio = sessionConfig.getAspectRatio();
925
+ // Default to "4:3" if no aspect ratio was set at all
926
+ if (captureAspectRatio == null) {
927
+ captureAspectRatio = "4:3";
928
+ }
929
+ Log.d(
930
+ TAG,
931
+ "capturePhoto: Using stored aspectRatio: " + captureAspectRatio
932
+ );
933
+ }
934
+
935
+ // Handle aspect ratio if no width/height specified
936
+ if (
937
+ width == null &&
938
+ height == null &&
939
+ captureAspectRatio != null &&
940
+ !captureAspectRatio.isEmpty()
941
+ ) {
942
+ // Get the original image dimensions
943
+ Bitmap originalBitmap = BitmapFactory.decodeByteArray(
944
+ bytes,
945
+ 0,
946
+ bytes.length
947
+ );
948
+ int originalWidth = originalBitmap.getWidth();
949
+ int originalHeight = originalBitmap.getHeight();
950
+
951
+ // Parse aspect ratio
952
+ String[] ratios = captureAspectRatio.split(":");
953
+ if (ratios.length == 2) {
954
+ try {
955
+ float widthRatio = Float.parseFloat(ratios[0]);
956
+ float heightRatio = Float.parseFloat(ratios[1]);
957
+
958
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
959
+ boolean isPortrait = originalHeight > originalWidth;
960
+ float targetAspectRatio = isPortrait
961
+ ? heightRatio / widthRatio
962
+ : widthRatio / heightRatio;
963
+ float originalAspectRatio =
964
+ (float) originalWidth / originalHeight;
965
+
966
+ int targetWidth, targetHeight;
967
+
968
+ if (originalAspectRatio > targetAspectRatio) {
969
+ // Original is wider than target - fit by height
970
+ targetHeight = originalHeight;
971
+ targetWidth = (int) (targetHeight * targetAspectRatio);
972
+ } else {
973
+ // Original is taller than target - fit by width
974
+ targetWidth = originalWidth;
975
+ targetHeight = (int) (targetWidth / targetAspectRatio);
976
+ }
977
+
978
+ // Center crop the image
979
+ int xOffset = (originalWidth - targetWidth) / 2;
980
+ int yOffset = (originalHeight - targetHeight) / 2;
981
+
982
+ Bitmap croppedBitmap = Bitmap.createBitmap(
983
+ originalBitmap,
984
+ xOffset,
985
+ yOffset,
986
+ targetWidth,
987
+ targetHeight
988
+ );
989
+
990
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
991
+ croppedBitmap.compress(
992
+ Bitmap.CompressFormat.JPEG,
993
+ quality,
994
+ stream
995
+ );
996
+ bytes = stream.toByteArray();
997
+
998
+ // Write EXIF data back to cropped image
999
+ bytes = writeExifToImageBytes(bytes, exifInterface);
1000
+
1001
+ originalBitmap.recycle();
1002
+ croppedBitmap.recycle();
1003
+ } catch (NumberFormatException e) {
1004
+ Log.e(
1005
+ TAG,
1006
+ "Invalid aspect ratio format: " + captureAspectRatio,
1007
+ e
1008
+ );
1009
+ }
1010
+ }
1011
+ } else if (width != null && height != null) {
1012
+ Bitmap bitmap = BitmapFactory.decodeByteArray(
1013
+ bytes,
1014
+ 0,
1015
+ bytes.length
1016
+ );
1017
+ Bitmap resizedBitmap = resizeBitmap(bitmap, width, height);
1018
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
1019
+ resizedBitmap.compress(
1020
+ Bitmap.CompressFormat.JPEG,
1021
+ quality,
1022
+ stream
1023
+ );
1024
+ bytes = stream.toByteArray();
1025
+
1026
+ // Write EXIF data back to resized image
1027
+ bytes = writeExifToImageBytes(bytes, exifInterface);
1028
+ } else {
1029
+ // For non-resized images, ensure EXIF is saved
1030
+ exifInterface.saveAttributes();
1031
+ bytes = new byte[(int) tempFile.length()];
1032
+ java.io.FileInputStream fis2 = new java.io.FileInputStream(
1033
+ tempFile
1034
+ );
1035
+ fis2.read(bytes);
1036
+ fis2.close();
1037
+ }
1038
+
1039
+ if (saveToGallery) {
1040
+ saveImageToGallery(bytes);
1041
+ }
1042
+
1043
+ String base64 = Base64.encodeToString(bytes, Base64.NO_WRAP);
1044
+
1045
+ tempFile.delete();
1046
+
1047
+ if (listener != null) {
1048
+ listener.onPictureTaken(base64, exifData);
1049
+ }
1050
+ } catch (Exception e) {
1051
+ Log.e(TAG, "capturePhoto: Error processing image", e);
1052
+ if (listener != null) {
1053
+ listener.onPictureTakenError(
1054
+ "Error processing image: " + e.getMessage()
1055
+ );
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ );
1061
+ }
1062
+
1063
+ private Bitmap resizeBitmap(Bitmap bitmap, int width, int height) {
1064
+ return Bitmap.createScaledBitmap(bitmap, width, height, true);
1065
+ }
1066
+
1067
+ private JSONObject getExifData(ExifInterface exifInterface) {
1068
+ JSONObject exifData = new JSONObject();
1069
+ try {
1070
+ // Add all available exif tags to a JSON object
1071
+ for (String[] tag : EXIF_TAGS) {
1072
+ String value = exifInterface.getAttribute(tag[0]);
1073
+ if (value != null) {
1074
+ exifData.put(tag[1], value);
1075
+ }
1076
+ }
1077
+ } catch (Exception e) {
1078
+ Log.e(TAG, "getExifData: Error reading exif data", e);
1079
+ }
1080
+ return exifData;
1081
+ }
1082
+
1083
+ private static final String[][] EXIF_TAGS = new String[][] {
1084
+ { ExifInterface.TAG_APERTURE_VALUE, "ApertureValue" },
1085
+ { ExifInterface.TAG_ARTIST, "Artist" },
1086
+ { ExifInterface.TAG_BITS_PER_SAMPLE, "BitsPerSample" },
1087
+ { ExifInterface.TAG_BRIGHTNESS_VALUE, "BrightnessValue" },
1088
+ { ExifInterface.TAG_CFA_PATTERN, "CFAPattern" },
1089
+ { ExifInterface.TAG_COLOR_SPACE, "ColorSpace" },
1090
+ { ExifInterface.TAG_COMPONENTS_CONFIGURATION, "ComponentsConfiguration" },
1091
+ { ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL, "CompressedBitsPerPixel" },
1092
+ { ExifInterface.TAG_COMPRESSION, "Compression" },
1093
+ { ExifInterface.TAG_CONTRAST, "Contrast" },
1094
+ { ExifInterface.TAG_COPYRIGHT, "Copyright" },
1095
+ { ExifInterface.TAG_CUSTOM_RENDERED, "CustomRendered" },
1096
+ { ExifInterface.TAG_DATETIME, "DateTime" },
1097
+ { ExifInterface.TAG_DATETIME_DIGITIZED, "DateTimeDigitized" },
1098
+ { ExifInterface.TAG_DATETIME_ORIGINAL, "DateTimeOriginal" },
1099
+ {
1100
+ ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION,
1101
+ "DeviceSettingDescription",
1102
+ },
1103
+ { ExifInterface.TAG_DIGITAL_ZOOM_RATIO, "DigitalZoomRatio" },
1104
+ { ExifInterface.TAG_DNG_VERSION, "DNGVersion" },
1105
+ { ExifInterface.TAG_EXIF_VERSION, "ExifVersion" },
1106
+ { ExifInterface.TAG_EXPOSURE_BIAS_VALUE, "ExposureBiasValue" },
1107
+ { ExifInterface.TAG_EXPOSURE_INDEX, "ExposureIndex" },
1108
+ { ExifInterface.TAG_EXPOSURE_MODE, "ExposureMode" },
1109
+ { ExifInterface.TAG_EXPOSURE_PROGRAM, "ExposureProgram" },
1110
+ { ExifInterface.TAG_EXPOSURE_TIME, "ExposureTime" },
1111
+ { ExifInterface.TAG_FILE_SOURCE, "FileSource" },
1112
+ { ExifInterface.TAG_FLASH, "Flash" },
1113
+ { ExifInterface.TAG_FLASHPIX_VERSION, "FlashpixVersion" },
1114
+ { ExifInterface.TAG_FLASH_ENERGY, "FlashEnergy" },
1115
+ { ExifInterface.TAG_FOCAL_LENGTH, "FocalLength" },
1116
+ { ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, "FocalLengthIn35mmFilm" },
1117
+ {
1118
+ ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT,
1119
+ "FocalPlaneResolutionUnit",
1120
+ },
1121
+ { ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, "FocalPlaneXResolution" },
1122
+ { ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, "FocalPlaneYResolution" },
1123
+ { ExifInterface.TAG_F_NUMBER, "FNumber" },
1124
+ { ExifInterface.TAG_GAIN_CONTROL, "GainControl" },
1125
+ { ExifInterface.TAG_GPS_ALTITUDE, "GPSAltitude" },
1126
+ { ExifInterface.TAG_GPS_ALTITUDE_REF, "GPSAltitudeRef" },
1127
+ { ExifInterface.TAG_GPS_AREA_INFORMATION, "GPSAreaInformation" },
1128
+ { ExifInterface.TAG_GPS_DATESTAMP, "GPSDateStamp" },
1129
+ { ExifInterface.TAG_GPS_DEST_BEARING, "GPSDestBearing" },
1130
+ { ExifInterface.TAG_GPS_DEST_BEARING_REF, "GPSDestBearingRef" },
1131
+ { ExifInterface.TAG_GPS_DEST_DISTANCE, "GPSDestDistance" },
1132
+ { ExifInterface.TAG_GPS_DEST_DISTANCE_REF, "GPSDestDistanceRef" },
1133
+ { ExifInterface.TAG_GPS_DEST_LATITUDE, "GPSDestLatitude" },
1134
+ { ExifInterface.TAG_GPS_DEST_LATITUDE_REF, "GPSDestLatitudeRef" },
1135
+ { ExifInterface.TAG_GPS_DEST_LONGITUDE, "GPSDestLongitude" },
1136
+ { ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, "GPSDestLongitudeRef" },
1137
+ { ExifInterface.TAG_GPS_DIFFERENTIAL, "GPSDifferential" },
1138
+ { ExifInterface.TAG_GPS_DOP, "GPSDOP" },
1139
+ { ExifInterface.TAG_GPS_IMG_DIRECTION, "GPSImgDirection" },
1140
+ { ExifInterface.TAG_GPS_IMG_DIRECTION_REF, "GPSImgDirectionRef" },
1141
+ { ExifInterface.TAG_GPS_LATITUDE, "GPSLatitude" },
1142
+ { ExifInterface.TAG_GPS_LATITUDE_REF, "GPSLatitudeRef" },
1143
+ { ExifInterface.TAG_GPS_LONGITUDE, "GPSLongitude" },
1144
+ { ExifInterface.TAG_GPS_LONGITUDE_REF, "GPSLongitudeRef" },
1145
+ { ExifInterface.TAG_GPS_MAP_DATUM, "GPSMapDatum" },
1146
+ { ExifInterface.TAG_GPS_MEASURE_MODE, "GPSMeasureMode" },
1147
+ { ExifInterface.TAG_GPS_PROCESSING_METHOD, "GPSProcessingMethod" },
1148
+ { ExifInterface.TAG_GPS_SATELLITES, "GPSSatellites" },
1149
+ { ExifInterface.TAG_GPS_SPEED, "GPSSpeed" },
1150
+ { ExifInterface.TAG_GPS_SPEED_REF, "GPSSpeedRef" },
1151
+ { ExifInterface.TAG_GPS_STATUS, "GPSStatus" },
1152
+ { ExifInterface.TAG_GPS_TIMESTAMP, "GPSTimeStamp" },
1153
+ { ExifInterface.TAG_GPS_TRACK, "GPSTrack" },
1154
+ { ExifInterface.TAG_GPS_TRACK_REF, "GPSTrackRef" },
1155
+ { ExifInterface.TAG_GPS_VERSION_ID, "GPSVersionID" },
1156
+ { ExifInterface.TAG_IMAGE_DESCRIPTION, "ImageDescription" },
1157
+ { ExifInterface.TAG_IMAGE_LENGTH, "ImageLength" },
1158
+ { ExifInterface.TAG_IMAGE_UNIQUE_ID, "ImageUniqueID" },
1159
+ { ExifInterface.TAG_IMAGE_WIDTH, "ImageWidth" },
1160
+ { ExifInterface.TAG_INTEROPERABILITY_INDEX, "InteroperabilityIndex" },
1161
+ { ExifInterface.TAG_ISO_SPEED, "ISOSpeed" },
1162
+ { ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, "ISOSpeedLatitudeyyy" },
1163
+ { ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, "ISOSpeedLatitudezzz" },
1164
+ { ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, "JPEGInterchangeFormat" },
1165
+ {
1166
+ ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH,
1167
+ "JPEGInterchangeFormatLength",
1168
+ },
1169
+ { ExifInterface.TAG_LIGHT_SOURCE, "LightSource" },
1170
+ { ExifInterface.TAG_MAKE, "Make" },
1171
+ { ExifInterface.TAG_MAKER_NOTE, "MakerNote" },
1172
+ { ExifInterface.TAG_MAX_APERTURE_VALUE, "MaxApertureValue" },
1173
+ { ExifInterface.TAG_METERING_MODE, "MeteringMode" },
1174
+ { ExifInterface.TAG_MODEL, "Model" },
1175
+ { ExifInterface.TAG_NEW_SUBFILE_TYPE, "NewSubfileType" },
1176
+ { ExifInterface.TAG_OECF, "OECF" },
1177
+ { ExifInterface.TAG_OFFSET_TIME, "OffsetTime" },
1178
+ { ExifInterface.TAG_OFFSET_TIME_DIGITIZED, "OffsetTimeDigitized" },
1179
+ { ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "OffsetTimeOriginal" },
1180
+ { ExifInterface.TAG_ORF_ASPECT_FRAME, "ORFAspectFrame" },
1181
+ { ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH, "ORFPreviewImageLength" },
1182
+ { ExifInterface.TAG_ORF_PREVIEW_IMAGE_START, "ORFPreviewImageStart" },
1183
+ { ExifInterface.TAG_ORF_THUMBNAIL_IMAGE, "ORFThumbnailImage" },
1184
+ { ExifInterface.TAG_ORIENTATION, "Orientation" },
1185
+ {
1186
+ ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION,
1187
+ "PhotometricInterpretation",
1188
+ },
1189
+ { ExifInterface.TAG_PIXEL_X_DIMENSION, "PixelXDimension" },
1190
+ { ExifInterface.TAG_PIXEL_Y_DIMENSION, "PixelYDimension" },
1191
+ { ExifInterface.TAG_PLANAR_CONFIGURATION, "PlanarConfiguration" },
1192
+ { ExifInterface.TAG_PRIMARY_CHROMATICITIES, "PrimaryChromaticities" },
1193
+ {
1194
+ ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX,
1195
+ "RecommendedExposureIndex",
1196
+ },
1197
+ { ExifInterface.TAG_REFERENCE_BLACK_WHITE, "ReferenceBlackWhite" },
1198
+ { ExifInterface.TAG_RELATED_SOUND_FILE, "RelatedSoundFile" },
1199
+ { ExifInterface.TAG_RESOLUTION_UNIT, "ResolutionUnit" },
1200
+ { ExifInterface.TAG_ROWS_PER_STRIP, "RowsPerStrip" },
1201
+ { ExifInterface.TAG_RW2_ISO, "RW2ISO" },
1202
+ { ExifInterface.TAG_RW2_JPG_FROM_RAW, "RW2JpgFromRaw" },
1203
+ { ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER, "RW2SensorBottomBorder" },
1204
+ { ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER, "RW2SensorLeftBorder" },
1205
+ { ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER, "RW2SensorRightBorder" },
1206
+ { ExifInterface.TAG_RW2_SENSOR_TOP_BORDER, "RW2SensorTopBorder" },
1207
+ { ExifInterface.TAG_SAMPLES_PER_PIXEL, "SamplesPerPixel" },
1208
+ { ExifInterface.TAG_SATURATION, "Saturation" },
1209
+ { ExifInterface.TAG_SCENE_CAPTURE_TYPE, "SceneCaptureType" },
1210
+ { ExifInterface.TAG_SCENE_TYPE, "SceneType" },
1211
+ { ExifInterface.TAG_SENSING_METHOD, "SensingMethod" },
1212
+ { ExifInterface.TAG_SENSITIVITY_TYPE, "SensitivityType" },
1213
+ { ExifInterface.TAG_SHARPNESS, "Sharpness" },
1214
+ { ExifInterface.TAG_SHUTTER_SPEED_VALUE, "ShutterSpeedValue" },
1215
+ { ExifInterface.TAG_SOFTWARE, "Software" },
1216
+ {
1217
+ ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE,
1218
+ "SpatialFrequencyResponse",
1219
+ },
1220
+ { ExifInterface.TAG_SPECTRAL_SENSITIVITY, "SpectralSensitivity" },
1221
+ {
1222
+ ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY,
1223
+ "StandardOutputSensitivity",
1224
+ },
1225
+ { ExifInterface.TAG_STRIP_BYTE_COUNTS, "StripByteCounts" },
1226
+ { ExifInterface.TAG_STRIP_OFFSETS, "StripOffsets" },
1227
+ { ExifInterface.TAG_SUBFILE_TYPE, "SubfileType" },
1228
+ { ExifInterface.TAG_SUBJECT_AREA, "SubjectArea" },
1229
+ { ExifInterface.TAG_SUBJECT_DISTANCE, "SubjectDistance" },
1230
+ { ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, "SubjectDistanceRange" },
1231
+ { ExifInterface.TAG_SUBJECT_LOCATION, "SubjectLocation" },
1232
+ { ExifInterface.TAG_SUBSEC_TIME, "SubSecTime" },
1233
+ { ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, "SubSecTimeDigitized" },
1234
+ { ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, "SubSecTimeOriginal" },
1235
+ { ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH, "ThumbnailImageLength" },
1236
+ { ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH, "ThumbnailImageWidth" },
1237
+ { ExifInterface.TAG_TRANSFER_FUNCTION, "TransferFunction" },
1238
+ { ExifInterface.TAG_USER_COMMENT, "UserComment" },
1239
+ { ExifInterface.TAG_WHITE_BALANCE, "WhiteBalance" },
1240
+ { ExifInterface.TAG_WHITE_POINT, "WhitePoint" },
1241
+ { ExifInterface.TAG_X_RESOLUTION, "XResolution" },
1242
+ { ExifInterface.TAG_Y_CB_CR_COEFFICIENTS, "YCbCrCoefficients" },
1243
+ { ExifInterface.TAG_Y_CB_CR_POSITIONING, "YCbCrPositioning" },
1244
+ { ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING, "YCbCrSubSampling" },
1245
+ { ExifInterface.TAG_Y_RESOLUTION, "YResolution" },
1246
+ };
1247
+
1248
+ private byte[] writeExifToImageBytes(
1249
+ byte[] imageBytes,
1250
+ ExifInterface sourceExif
1251
+ ) {
1252
+ try {
1253
+ // Create a temporary file to write the image with EXIF
1254
+ File tempExifFile = File.createTempFile(
1255
+ "temp_exif",
1256
+ ".jpg",
1257
+ context.getCacheDir()
1258
+ );
1259
+
1260
+ // Write the image bytes to temp file
1261
+ java.io.FileOutputStream fos = new java.io.FileOutputStream(tempExifFile);
1262
+ fos.write(imageBytes);
1263
+ fos.close();
1264
+
1265
+ // Create new ExifInterface for the temp file and copy all EXIF data
1266
+ ExifInterface newExif = new ExifInterface(tempExifFile.getAbsolutePath());
1267
+
1268
+ // Copy all EXIF attributes from source to new
1269
+ for (String[] tag : EXIF_TAGS) {
1270
+ String value = sourceExif.getAttribute(tag[0]);
1271
+ if (value != null) {
1272
+ newExif.setAttribute(tag[0], value);
1273
+ }
1274
+ }
1275
+
1276
+ // Save the EXIF data
1277
+ newExif.saveAttributes();
1278
+
1279
+ // Read the file back with EXIF embedded
1280
+ byte[] result = new byte[(int) tempExifFile.length()];
1281
+ java.io.FileInputStream fis = new java.io.FileInputStream(tempExifFile);
1282
+ fis.read(result);
1283
+ fis.close();
1284
+
1285
+ // Clean up temp file
1286
+ tempExifFile.delete();
1287
+
1288
+ return result;
1289
+ } catch (Exception e) {
1290
+ Log.e(TAG, "writeExifToImageBytes: Error writing EXIF data", e);
1291
+ return imageBytes; // Return original bytes if error
1292
+ }
1293
+ }
1294
+
1295
+ public void captureSample(int quality) {
1296
+ Log.d(
1297
+ TAG,
1298
+ "captureSample: Starting sample capture with quality: " + quality
1299
+ );
1300
+
1301
+ if (sampleImageCapture == null) {
1302
+ if (listener != null) {
1303
+ listener.onSampleTakenError("Camera not ready");
1304
+ }
1305
+ return;
1306
+ }
1307
+
1308
+ sampleImageCapture.takePicture(
1309
+ cameraExecutor,
1310
+ new ImageCapture.OnImageCapturedCallback() {
1311
+ @Override
1312
+ public void onError(@NonNull ImageCaptureException exception) {
1313
+ Log.e(TAG, "captureSample: Sample capture failed", exception);
1314
+ if (listener != null) {
1315
+ listener.onSampleTakenError(
1316
+ "Sample capture failed: " + exception.getMessage()
1317
+ );
1318
+ }
1319
+ }
1320
+
1321
+ @Override
1322
+ public void onCaptureSuccess(@NonNull ImageProxy image) {
1323
+ try {
1324
+ // Convert ImageProxy to byte array
1325
+ byte[] bytes = imageProxyToByteArray(image);
1326
+ String base64 = Base64.encodeToString(bytes, Base64.NO_WRAP);
1327
+
1328
+ if (listener != null) {
1329
+ listener.onSampleTaken(base64);
1330
+ }
1331
+ } catch (Exception e) {
1332
+ Log.e(TAG, "captureSample: Error processing sample", e);
1333
+ if (listener != null) {
1334
+ listener.onSampleTakenError(
1335
+ "Error processing sample: " + e.getMessage()
1336
+ );
1337
+ }
1338
+ } finally {
1339
+ image.close();
1340
+ }
1341
+ }
1342
+ }
1343
+ );
1344
+ }
1345
+
1346
+ private byte[] imageProxyToByteArray(ImageProxy image) {
1347
+ ImageProxy.PlaneProxy[] planes = image.getPlanes();
1348
+ ByteBuffer buffer = planes[0].getBuffer();
1349
+ byte[] bytes = new byte[buffer.remaining()];
1350
+ buffer.get(bytes);
1351
+ return bytes;
1352
+ }
1353
+
1354
+ // not workin for xiaomi https://xiaomi.eu/community/threads/mi-11-ultra-unable-to-access-camera-lenses-in-apps-camera2-api.61456/
1355
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
1356
+ public static List<
1357
+ com.ahm.capacitor.camera.preview.model.CameraDevice
1358
+ > getAvailableDevicesStatic(Context context) {
1359
+ Log.d(
1360
+ TAG,
1361
+ "getAvailableDevicesStatic: Starting CameraX device enumeration with getPhysicalCameraInfos."
1362
+ );
1363
+ List<com.ahm.capacitor.camera.preview.model.CameraDevice> devices =
1364
+ new ArrayList<>();
1365
+ try {
1366
+ ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
1367
+ ProcessCameraProvider.getInstance(context);
1368
+ ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
1369
+ CameraManager cameraManager = (CameraManager) context.getSystemService(
1370
+ Context.CAMERA_SERVICE
1371
+ );
1372
+
1373
+ for (CameraInfo cameraInfo : cameraProvider.getAvailableCameraInfos()) {
1374
+ String logicalCameraId = Camera2CameraInfo.from(
1375
+ cameraInfo
1376
+ ).getCameraId();
1377
+ String position = isBackCamera(cameraInfo) ? "rear" : "front";
1378
+
1379
+ // Add logical camera
1380
+ float minZoom = Objects.requireNonNull(
1381
+ cameraInfo.getZoomState().getValue()
1382
+ ).getMinZoomRatio();
1383
+ float maxZoom = cameraInfo.getZoomState().getValue().getMaxZoomRatio();
1384
+ List<LensInfo> logicalLenses = new ArrayList<>();
1385
+ logicalLenses.add(new LensInfo(4.25f, "wideAngle", 1.0f, maxZoom));
1386
+ devices.add(
1387
+ new com.ahm.capacitor.camera.preview.model.CameraDevice(
1388
+ logicalCameraId,
1389
+ "Logical Camera (" + position + ")",
1390
+ position,
1391
+ logicalLenses,
1392
+ minZoom,
1393
+ maxZoom,
1394
+ true
1395
+ )
1396
+ );
1397
+ Log.d(
1398
+ TAG,
1399
+ "Found logical camera: " +
1400
+ logicalCameraId +
1401
+ " (" +
1402
+ position +
1403
+ ") with zoom " +
1404
+ minZoom +
1405
+ "-" +
1406
+ maxZoom
1407
+ );
1408
+
1409
+ // Get and add physical cameras
1410
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
1411
+ Set<CameraInfo> physicalCameraInfos =
1412
+ cameraInfo.getPhysicalCameraInfos();
1413
+ if (physicalCameraInfos.isEmpty()) continue;
1414
+
1415
+ Log.d(
1416
+ TAG,
1417
+ "Logical camera " +
1418
+ logicalCameraId +
1419
+ " has " +
1420
+ physicalCameraInfos.size() +
1421
+ " physical cameras."
1422
+ );
1423
+
1424
+ for (CameraInfo physicalCameraInfo : physicalCameraInfos) {
1425
+ String physicalId = Camera2CameraInfo.from(
1426
+ physicalCameraInfo
1427
+ ).getCameraId();
1428
+ if (physicalId.equals(logicalCameraId)) continue; // Already added as logical
1429
+
1430
+ try {
1431
+ CameraCharacteristics characteristics =
1432
+ cameraManager.getCameraCharacteristics(physicalId);
1433
+ String deviceType = "wideAngle";
1434
+ float[] focalLengths = characteristics.get(
1435
+ CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
1436
+ );
1437
+ android.util.SizeF sensorSize = characteristics.get(
1438
+ CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE
1439
+ );
1440
+
1441
+ if (
1442
+ focalLengths != null &&
1443
+ focalLengths.length > 0 &&
1444
+ sensorSize != null &&
1445
+ sensorSize.getWidth() > 0
1446
+ ) {
1447
+ double fov =
1448
+ 2 *
1449
+ Math.toDegrees(
1450
+ Math.atan(sensorSize.getWidth() / (2 * focalLengths[0]))
1451
+ );
1452
+ if (fov > 90) deviceType = "ultraWide";
1453
+ else if (fov < 40) deviceType = "telephoto";
1454
+ } else if (focalLengths != null && focalLengths.length > 0) {
1455
+ if (focalLengths[0] < 3.0f) deviceType = "ultraWide";
1456
+ else if (focalLengths[0] > 5.0f) deviceType = "telephoto";
1457
+ }
1458
+
1459
+ float physicalMinZoom = 1.0f;
1460
+ float physicalMaxZoom = 1.0f;
1461
+ if (
1462
+ android.os.Build.VERSION.SDK_INT >=
1463
+ android.os.Build.VERSION_CODES.R
1464
+ ) {
1465
+ android.util.Range<Float> zoomRange = characteristics.get(
1466
+ CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE
1467
+ );
1468
+ if (zoomRange != null) {
1469
+ physicalMinZoom = zoomRange.getLower();
1470
+ physicalMaxZoom = zoomRange.getUpper();
1471
+ }
1472
+ }
1473
+
1474
+ String label = "Physical " + deviceType + " (" + position + ")";
1475
+ List<LensInfo> physicalLenses = new ArrayList<>();
1476
+ physicalLenses.add(
1477
+ new LensInfo(
1478
+ focalLengths != null ? focalLengths[0] : 4.25f,
1479
+ deviceType,
1480
+ 1.0f,
1481
+ physicalMaxZoom
1482
+ )
1483
+ );
1484
+
1485
+ devices.add(
1486
+ new com.ahm.capacitor.camera.preview.model.CameraDevice(
1487
+ physicalId,
1488
+ label,
1489
+ position,
1490
+ physicalLenses,
1491
+ physicalMinZoom,
1492
+ physicalMaxZoom,
1493
+ false
1494
+ )
1495
+ );
1496
+ Log.d(
1497
+ TAG,
1498
+ "Found physical camera: " + physicalId + " (" + label + ")"
1499
+ );
1500
+ } catch (CameraAccessException e) {
1501
+ Log.e(
1502
+ TAG,
1503
+ "Failed to access characteristics for physical camera " +
1504
+ physicalId,
1505
+ e
1506
+ );
1507
+ }
1508
+ }
1509
+ }
1510
+ }
1511
+ return devices;
1512
+ } catch (Exception e) {
1513
+ Log.e(TAG, "getAvailableDevicesStatic: Error getting devices", e);
1514
+ return Collections.emptyList();
1515
+ }
1516
+ }
1517
+
1518
+ public static ZoomFactors getZoomFactorsStatic() {
1519
+ try {
1520
+ // For static method, return default zoom factors
1521
+ // We can try to detect if ultra-wide is available by checking device list
1522
+
1523
+ float minZoom = 1.0f;
1524
+ float maxZoom = 10.0f;
1525
+
1526
+ Log.d(
1527
+ TAG,
1528
+ "getZoomFactorsStatic: Final range - minZoom: " +
1529
+ minZoom +
1530
+ ", maxZoom: " +
1531
+ maxZoom
1532
+ );
1533
+ LensInfo defaultLens = new LensInfo(4.25f, "wideAngle", 1.0f, 1.0f);
1534
+ return new ZoomFactors(minZoom, maxZoom, 1.0f, defaultLens);
1535
+ } catch (Exception e) {
1536
+ Log.e(TAG, "getZoomFactorsStatic: Error getting zoom factors", e);
1537
+ LensInfo defaultLens = new LensInfo(4.25f, "wideAngle", 1.0f, 1.0f);
1538
+ return new ZoomFactors(1.0f, 10.0f, 1.0f, defaultLens);
1539
+ }
1540
+ }
1541
+
1542
+ public ZoomFactors getZoomFactors() {
1543
+ if (camera == null) {
1544
+ return getZoomFactorsStatic();
1545
+ }
1546
+
1547
+ try {
1548
+ // Get the current zoom from active camera
1549
+ float currentZoom = Objects.requireNonNull(
1550
+ camera.getCameraInfo().getZoomState().getValue()
1551
+ ).getZoomRatio();
1552
+ float minZoom = camera
1553
+ .getCameraInfo()
1554
+ .getZoomState()
1555
+ .getValue()
1556
+ .getMinZoomRatio();
1557
+ float maxZoom = camera
1558
+ .getCameraInfo()
1559
+ .getZoomState()
1560
+ .getValue()
1561
+ .getMaxZoomRatio();
1562
+
1563
+ Log.d(
1564
+ TAG,
1565
+ "getZoomFactors: Combined range - minZoom: " +
1566
+ minZoom +
1567
+ ", maxZoom: " +
1568
+ maxZoom +
1569
+ ", currentZoom: " +
1570
+ currentZoom
1571
+ );
1572
+
1573
+ return new ZoomFactors(
1574
+ minZoom,
1575
+ maxZoom,
1576
+ currentZoom,
1577
+ getCurrentLensInfo()
1578
+ );
1579
+ } catch (Exception e) {
1580
+ Log.e(TAG, "getZoomFactors: Error getting zoom factors", e);
1581
+ return new ZoomFactors(1.0f, 1.0f, 1.0f, getCurrentLensInfo());
1582
+ }
1583
+ }
1584
+
1585
+ private LensInfo getCurrentLensInfo() {
1586
+ if (camera == null) {
1587
+ return new LensInfo(4.25f, "wideAngle", 1.0f, 1.0f);
1588
+ }
1589
+
1590
+ try {
1591
+ float currentZoom = Objects.requireNonNull(
1592
+ camera.getCameraInfo().getZoomState().getValue()
1593
+ ).getZoomRatio();
1594
+
1595
+ // Determine device type based on zoom capabilities
1596
+ String deviceType = "wideAngle";
1597
+ float baseZoomRatio = 1.0f;
1598
+
1599
+ float digitalZoom = currentZoom / baseZoomRatio;
1600
+
1601
+ return new LensInfo(4.25f, deviceType, baseZoomRatio, digitalZoom);
1602
+ } catch (Exception e) {
1603
+ Log.e(TAG, "getCurrentLensInfo: Error getting lens info", e);
1604
+ return new LensInfo(4.25f, "wideAngle", 1.0f, 1.0f);
1605
+ }
1606
+ }
1607
+
1608
+ public void setZoom(float zoomRatio, boolean autoFocus) throws Exception {
1609
+ if (camera == null) {
1610
+ throw new Exception("Camera not initialized");
1611
+ }
1612
+
1613
+ Log.d(TAG, "setZoom: Requested zoom ratio: " + zoomRatio);
1614
+
1615
+ // Just let CameraX handle everything - it should automatically switch lenses
1616
+ try {
1617
+ ListenableFuture<Void> zoomFuture = camera
1618
+ .getCameraControl()
1619
+ .setZoomRatio(zoomRatio);
1620
+
1621
+ // Add callback to see what actually happened
1622
+ zoomFuture.addListener(
1623
+ () -> {
1624
+ try {
1625
+ zoomFuture.get();
1626
+ Log.d(TAG, "Zoom successfully set to " + zoomRatio);
1627
+ // Trigger autofocus after zoom if requested
1628
+ if (autoFocus) {
1629
+ triggerAutoFocus();
1630
+ }
1631
+ } catch (Exception e) {
1632
+ Log.e(TAG, "Error setting zoom: " + e.getMessage());
1633
+ }
1634
+ },
1635
+ ContextCompat.getMainExecutor(context)
1636
+ );
1637
+ } catch (Exception e) {
1638
+ Log.e(TAG, "Failed to set zoom: " + e.getMessage());
1639
+ throw e;
1640
+ }
1641
+ }
1642
+
1643
+ public void setFocus(float x, float y) throws Exception {
1644
+ if (camera == null) {
1645
+ throw new Exception("Camera not initialized");
1646
+ }
1647
+
1648
+ if (previewView == null) {
1649
+ throw new Exception("Preview view not initialized");
1650
+ }
1651
+
1652
+ // Validate that coordinates are within bounds (0-1 range)
1653
+ if (x < 0f || x > 1f || y < 0f || y > 1f) {
1654
+ Log.w(TAG, "setFocus: Coordinates out of bounds - x: " + x + ", y: " + y);
1655
+ throw new Exception("Focus coordinates must be between 0 and 1");
1656
+ }
1657
+
1658
+ // Cancel any ongoing focus operation
1659
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
1660
+ Log.d(TAG, "setFocus: Cancelling previous focus operation");
1661
+ currentFocusFuture.cancel(true);
1662
+ }
1663
+
1664
+ int viewWidth = previewView.getWidth();
1665
+ int viewHeight = previewView.getHeight();
1666
+
1667
+ if (viewWidth <= 0 || viewHeight <= 0) {
1668
+ throw new Exception(
1669
+ "Preview view has invalid dimensions: " + viewWidth + "x" + viewHeight
1670
+ );
1671
+ }
1672
+
1673
+ // Only show focus indicator after validation passes
1674
+ float indicatorX = x * viewWidth;
1675
+ float indicatorY = y * viewHeight;
1676
+ showFocusIndicator(indicatorX, indicatorY);
1677
+
1678
+ // Create MeteringPoint using the preview view
1679
+ MeteringPointFactory factory = previewView.getMeteringPointFactory();
1680
+ MeteringPoint point = factory.createPoint(x * viewWidth, y * viewHeight);
1681
+
1682
+ // Create focus and metering action
1683
+ FocusMeteringAction action = new FocusMeteringAction.Builder(
1684
+ point,
1685
+ FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE
1686
+ )
1687
+ .setAutoCancelDuration(3, TimeUnit.SECONDS) // Auto-cancel after 3 seconds
1688
+ .build();
1689
+
1690
+ try {
1691
+ currentFocusFuture = camera
1692
+ .getCameraControl()
1693
+ .startFocusAndMetering(action);
1694
+
1695
+ currentFocusFuture.addListener(
1696
+ () -> {
1697
+ try {
1698
+ FocusMeteringResult result = currentFocusFuture.get();
1699
+ } catch (Exception e) {
1700
+ // Handle cancellation gracefully - this is expected when rapid taps occur
1701
+ if (
1702
+ e.getMessage() != null &&
1703
+ (e
1704
+ .getMessage()
1705
+ .contains("Cancelled by another startFocusAndMetering") ||
1706
+ e.getMessage().contains("OperationCanceledException") ||
1707
+ e
1708
+ .getClass()
1709
+ .getSimpleName()
1710
+ .contains("OperationCanceledException"))
1711
+ ) {
1712
+ Log.d(
1713
+ TAG,
1714
+ "Focus operation was cancelled by a newer focus request"
1715
+ );
1716
+ } else {
1717
+ Log.e(TAG, "Error during focus: " + e.getMessage());
1718
+ }
1719
+ } finally {
1720
+ // Clear the reference if this is still the current operation
1721
+ if (currentFocusFuture != null && currentFocusFuture.isDone()) {
1722
+ currentFocusFuture = null;
1723
+ }
1724
+ }
1725
+ },
1726
+ ContextCompat.getMainExecutor(context)
1727
+ );
1728
+ } catch (Exception e) {
1729
+ currentFocusFuture = null;
1730
+ Log.e(TAG, "Failed to set focus: " + e.getMessage());
1731
+ throw e;
1732
+ }
1733
+ }
1734
+
1735
+ private void showFocusIndicator(float x, float y) {
1736
+ if (previewContainer == null) {
1737
+ Log.w(TAG, "showFocusIndicator: previewContainer is null");
1738
+ return;
1739
+ }
1740
+
1741
+ // Check if container has been laid out
1742
+ if (previewContainer.getWidth() == 0 || previewContainer.getHeight() == 0) {
1743
+ Log.w(
1744
+ TAG,
1745
+ "showFocusIndicator: previewContainer not laid out yet, posting to run after layout"
1746
+ );
1747
+ previewContainer.post(() -> showFocusIndicator(x, y));
1748
+ return;
1749
+ }
1750
+
1751
+ // Remove any existing focus indicator
1752
+ if (focusIndicatorView != null) {
1753
+ previewContainer.removeView(focusIndicatorView);
1754
+ focusIndicatorView = null;
1755
+ }
1756
+
1757
+ // Create an elegant focus indicator
1758
+ View container = new View(context);
1759
+ int size = (int) (60 * context.getResources().getDisplayMetrics().density); // 60dp size
1760
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size, size);
1761
+
1762
+ // Center the indicator on the touch point with bounds checking
1763
+ int containerWidth = previewContainer.getWidth();
1764
+ int containerHeight = previewContainer.getHeight();
1765
+
1766
+ params.leftMargin = Math.max(
1767
+ 0,
1768
+ Math.min((int) (x - size / 2), containerWidth - size)
1769
+ );
1770
+ params.topMargin = Math.max(
1771
+ 0,
1772
+ Math.min((int) (y - size / 2), containerHeight - size)
1773
+ );
1774
+
1775
+ // Create an elegant focus ring - white stroke with transparent center
1776
+ GradientDrawable drawable = new GradientDrawable();
1777
+ drawable.setShape(GradientDrawable.OVAL);
1778
+ drawable.setStroke(
1779
+ (int) (2 * context.getResources().getDisplayMetrics().density),
1780
+ Color.WHITE
1781
+ ); // 2dp white stroke
1782
+ drawable.setColor(Color.TRANSPARENT); // Transparent center
1783
+ container.setBackground(drawable);
1784
+
1785
+ focusIndicatorView = container;
1786
+
1787
+ // Set initial state for smooth animation
1788
+ focusIndicatorView.setAlpha(1f); // Start visible
1789
+ focusIndicatorView.setScaleX(1.8f); // Start larger for scale-in effect
1790
+ focusIndicatorView.setScaleY(1.8f);
1791
+ focusIndicatorView.setVisibility(View.VISIBLE);
1792
+
1793
+ // Ensure container doesn't intercept touch events
1794
+ container.setClickable(false);
1795
+ container.setFocusable(false);
1796
+
1797
+ // Ensure the focus indicator has a high elevation for visibility
1798
+ if (
1799
+ android.os.Build.VERSION.SDK_INT >=
1800
+ android.os.Build.VERSION_CODES.LOLLIPOP
1801
+ ) {
1802
+ focusIndicatorView.setElevation(10f);
1803
+ }
1804
+
1805
+ // Add to container first
1806
+ previewContainer.addView(focusIndicatorView, params);
1807
+
1808
+ // Fix z-ordering: ensure focus indicator is always on top
1809
+ focusIndicatorView.bringToFront();
1810
+
1811
+ // Force a layout pass to ensure the view is properly positioned
1812
+ previewContainer.requestLayout();
1813
+
1814
+ // Smooth scale down animation with easing (no fade needed since we start visible)
1815
+ ScaleAnimation scaleAnimation = new ScaleAnimation(
1816
+ 1.8f,
1817
+ 1.0f,
1818
+ 1.8f,
1819
+ 1.0f,
1820
+ Animation.RELATIVE_TO_SELF,
1821
+ 0.5f,
1822
+ Animation.RELATIVE_TO_SELF,
1823
+ 0.5f
1824
+ );
1825
+ scaleAnimation.setDuration(300);
1826
+ scaleAnimation.setInterpolator(
1827
+ new android.view.animation.OvershootInterpolator(1.2f)
1828
+ );
1829
+
1830
+ // Start the animation
1831
+ focusIndicatorView.startAnimation(scaleAnimation);
1832
+
1833
+ // Schedule fade out and removal with smoother timing
1834
+ focusIndicatorView.postDelayed(
1835
+ new Runnable() {
1836
+ @Override
1837
+ public void run() {
1838
+ if (focusIndicatorView != null) {
1839
+ // Smooth fade to semi-transparent
1840
+ AlphaAnimation fadeToTransparent = new AlphaAnimation(1f, 0.4f);
1841
+ fadeToTransparent.setDuration(400);
1842
+ fadeToTransparent.setInterpolator(
1843
+ new android.view.animation.AccelerateInterpolator()
1844
+ );
1845
+
1846
+ fadeToTransparent.setAnimationListener(
1847
+ new Animation.AnimationListener() {
1848
+ @Override
1849
+ public void onAnimationStart(Animation animation) {
1850
+ Log.d(TAG, "showFocusIndicator: Fade to transparent started");
1851
+ }
1852
+
1853
+ @Override
1854
+ public void onAnimationEnd(Animation animation) {
1855
+ Log.d(
1856
+ TAG,
1857
+ "showFocusIndicator: Fade to transparent ended, starting final fade out"
1858
+ );
1859
+ // Final smooth fade out and scale down
1860
+ if (focusIndicatorView != null) {
1861
+ AnimationSet finalAnimation = new AnimationSet(false);
1862
+
1863
+ AlphaAnimation finalFadeOut = new AlphaAnimation(0.4f, 0f);
1864
+ finalFadeOut.setDuration(500);
1865
+ finalFadeOut.setStartOffset(300);
1866
+ finalFadeOut.setInterpolator(
1867
+ new android.view.animation.AccelerateInterpolator()
1868
+ );
1869
+
1870
+ ScaleAnimation finalScaleDown = new ScaleAnimation(
1871
+ 1.0f,
1872
+ 0.9f,
1873
+ 1.0f,
1874
+ 0.9f,
1875
+ Animation.RELATIVE_TO_SELF,
1876
+ 0.5f,
1877
+ Animation.RELATIVE_TO_SELF,
1878
+ 0.5f
1879
+ );
1880
+ finalScaleDown.setDuration(500);
1881
+ finalScaleDown.setStartOffset(300);
1882
+ finalScaleDown.setInterpolator(
1883
+ new android.view.animation.AccelerateInterpolator()
1884
+ );
1885
+
1886
+ finalAnimation.addAnimation(finalFadeOut);
1887
+ finalAnimation.addAnimation(finalScaleDown);
1888
+
1889
+ finalAnimation.setAnimationListener(
1890
+ new Animation.AnimationListener() {
1891
+ @Override
1892
+ public void onAnimationStart(Animation animation) {
1893
+ Log.d(
1894
+ TAG,
1895
+ "showFocusIndicator: Final animation started"
1896
+ );
1897
+ }
1898
+
1899
+ @Override
1900
+ public void onAnimationEnd(Animation animation) {
1901
+ Log.d(
1902
+ TAG,
1903
+ "showFocusIndicator: Final animation ended, removing indicator"
1904
+ );
1905
+ // Remove the focus indicator
1906
+ if (
1907
+ focusIndicatorView != null &&
1908
+ previewContainer != null
1909
+ ) {
1910
+ previewContainer.removeView(focusIndicatorView);
1911
+ focusIndicatorView = null;
1912
+ }
1913
+ }
1914
+
1915
+ @Override
1916
+ public void onAnimationRepeat(Animation animation) {}
1917
+ }
1918
+ );
1919
+
1920
+ focusIndicatorView.startAnimation(finalAnimation);
1921
+ }
1922
+ }
1923
+
1924
+ @Override
1925
+ public void onAnimationRepeat(Animation animation) {}
1926
+ }
1927
+ );
1928
+
1929
+ focusIndicatorView.startAnimation(fadeToTransparent);
1930
+ }
1931
+ }
1932
+ },
1933
+ 800
1934
+ ); // Optimal timing for smooth focus feedback
1935
+ }
1936
+
1937
+ public static List<Size> getSupportedPictureSizes(String facing) {
1938
+ List<Size> sizes = new ArrayList<>();
1939
+ try {
1940
+ CameraSelector.Builder builder = new CameraSelector.Builder();
1941
+ if ("front".equals(facing)) {
1942
+ builder.requireLensFacing(CameraSelector.LENS_FACING_FRONT);
1943
+ } else {
1944
+ builder.requireLensFacing(CameraSelector.LENS_FACING_BACK);
1945
+ }
1946
+
1947
+ // This part is complex because we need characteristics, which are not directly on CameraInfo.
1948
+ // For now, returning a static list of common sizes.
1949
+ // A more advanced implementation would use Camera2interop to get StreamConfigurationMap.
1950
+ sizes.add(new Size(4032, 3024));
1951
+ sizes.add(new Size(1920, 1080));
1952
+ sizes.add(new Size(1280, 720));
1953
+ sizes.add(new Size(640, 480));
1954
+ } catch (Exception e) {
1955
+ Log.e(TAG, "Error getting supported picture sizes", e);
1956
+ }
1957
+ return sizes;
1958
+ }
1959
+
1960
+ private void setZoomInternal(float zoomRatio) {
1961
+ if (camera != null) {
1962
+ try {
1963
+ float minZoom = Objects.requireNonNull(
1964
+ camera.getCameraInfo().getZoomState().getValue()
1965
+ ).getMinZoomRatio();
1966
+ float maxZoom = camera
1967
+ .getCameraInfo()
1968
+ .getZoomState()
1969
+ .getValue()
1970
+ .getMaxZoomRatio();
1971
+ float currentZoom = camera
1972
+ .getCameraInfo()
1973
+ .getZoomState()
1974
+ .getValue()
1975
+ .getZoomRatio();
1976
+
1977
+ Log.d(
1978
+ TAG,
1979
+ "setZoomInternal: Current camera range: " +
1980
+ minZoom +
1981
+ "-" +
1982
+ maxZoom +
1983
+ ", current: " +
1984
+ currentZoom
1985
+ );
1986
+ Log.d(TAG, "setZoomInternal: Requesting zoom: " + zoomRatio);
1987
+
1988
+ // Try to set zoom directly - let CameraX handle lens switching
1989
+ ListenableFuture<Void> zoomFuture = camera
1990
+ .getCameraControl()
1991
+ .setZoomRatio(zoomRatio);
1992
+
1993
+ zoomFuture.addListener(
1994
+ () -> {
1995
+ try {
1996
+ zoomFuture.get(); // Check if zoom was successful
1997
+ float newZoom = Objects.requireNonNull(
1998
+ camera.getCameraInfo().getZoomState().getValue()
1999
+ ).getZoomRatio();
2000
+ Log.d(
2001
+ TAG,
2002
+ "setZoomInternal: Zoom set successfully to " +
2003
+ newZoom +
2004
+ " (requested: " +
2005
+ zoomRatio +
2006
+ ")"
2007
+ );
2008
+
2009
+ // Check if CameraX switched cameras
2010
+ String newCameraId = getCameraId(camera.getCameraInfo());
2011
+ if (!newCameraId.equals(currentDeviceId)) {
2012
+ currentDeviceId = newCameraId;
2013
+ Log.d(
2014
+ TAG,
2015
+ "setZoomInternal: CameraX switched to camera: " + newCameraId
2016
+ );
2017
+ }
2018
+ } catch (Exception e) {
2019
+ Log.w(
2020
+ TAG,
2021
+ "setZoomInternal: Zoom operation failed: " + e.getMessage()
2022
+ );
2023
+ // Fallback: clamp to current camera's range
2024
+ float clampedZoom = Math.max(
2025
+ minZoom,
2026
+ Math.min(zoomRatio, maxZoom)
2027
+ );
2028
+ camera.getCameraControl().setZoomRatio(clampedZoom);
2029
+ Log.d(
2030
+ TAG,
2031
+ "setZoomInternal: Fallback - clamped zoom to " + clampedZoom
2032
+ );
2033
+ }
2034
+ },
2035
+ mainExecutor
2036
+ );
2037
+ } catch (Exception e) {
2038
+ Log.e(TAG, "setZoomInternal: Error setting zoom", e);
2039
+ }
2040
+ }
2041
+ }
2042
+
2043
+ public static List<String> getSupportedFlashModesStatic() {
2044
+ try {
2045
+ // For static method, we can return common flash modes
2046
+ // Most modern cameras support these modes
2047
+ return Arrays.asList("off", "on", "auto");
2048
+ } catch (Exception e) {
2049
+ Log.e(TAG, "getSupportedFlashModesStatic: Error getting flash modes", e);
2050
+ return Collections.singletonList("off");
2051
+ }
2052
+ }
2053
+
2054
+ public List<String> getSupportedFlashModes() {
2055
+ if (camera == null) {
2056
+ return getSupportedFlashModesStatic();
2057
+ }
2058
+
2059
+ try {
2060
+ boolean hasFlash = camera.getCameraInfo().hasFlashUnit();
2061
+ if (hasFlash) {
2062
+ return Arrays.asList("off", "on", "auto");
2063
+ } else {
2064
+ return Collections.singletonList("off");
2065
+ }
2066
+ } catch (Exception e) {
2067
+ Log.e(TAG, "getSupportedFlashModes: Error getting flash modes", e);
2068
+ return Collections.singletonList("off");
2069
+ }
2070
+ }
2071
+
2072
+ public String getFlashMode() {
2073
+ switch (currentFlashMode) {
2074
+ case ImageCapture.FLASH_MODE_ON:
2075
+ return "on";
2076
+ case ImageCapture.FLASH_MODE_AUTO:
2077
+ return "auto";
2078
+ default:
2079
+ return "off";
2080
+ }
2081
+ }
2082
+
2083
+ public void setFlashMode(String mode) {
2084
+ int flashMode;
2085
+ switch (mode) {
2086
+ case "on":
2087
+ flashMode = ImageCapture.FLASH_MODE_ON;
2088
+ break;
2089
+ case "auto":
2090
+ flashMode = ImageCapture.FLASH_MODE_AUTO;
2091
+ break;
2092
+ default:
2093
+ flashMode = ImageCapture.FLASH_MODE_OFF;
2094
+ break;
2095
+ }
2096
+
2097
+ currentFlashMode = flashMode;
2098
+
2099
+ if (imageCapture != null) {
2100
+ imageCapture.setFlashMode(flashMode);
2101
+ }
2102
+ if (sampleImageCapture != null) {
2103
+ sampleImageCapture.setFlashMode(flashMode);
2104
+ }
2105
+ }
2106
+
2107
+ public String getCurrentDeviceId() {
2108
+ return currentDeviceId != null ? currentDeviceId : "unknown";
2109
+ }
2110
+
2111
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
2112
+ public void switchToDevice(String deviceId) {
2113
+ Log.d(TAG, "switchToDevice: Attempting to switch to device " + deviceId);
2114
+
2115
+ mainExecutor.execute(() -> {
2116
+ try {
2117
+ // Standard physical device selection logic...
2118
+ List<CameraInfo> cameraInfos = cameraProvider.getAvailableCameraInfos();
2119
+ CameraInfo targetCameraInfo = null;
2120
+ for (CameraInfo cameraInfo : cameraInfos) {
2121
+ if (
2122
+ deviceId.equals(Camera2CameraInfo.from(cameraInfo).getCameraId())
2123
+ ) {
2124
+ targetCameraInfo = cameraInfo;
2125
+ break;
2126
+ }
2127
+ }
2128
+
2129
+ if (targetCameraInfo != null) {
2130
+ Log.d(
2131
+ TAG,
2132
+ "switchToDevice: Found matching CameraInfo for deviceId: " +
2133
+ deviceId
2134
+ );
2135
+ final CameraInfo finalTarget = targetCameraInfo;
2136
+
2137
+ // This filter will receive a list of all cameras and must return the one we want.
2138
+
2139
+ currentCameraSelector = new CameraSelector.Builder()
2140
+ .addCameraFilter(cameras -> {
2141
+ // This filter will receive a list of all cameras and must return the one we want.
2142
+ return Collections.singletonList(finalTarget);
2143
+ })
2144
+ .build();
2145
+ currentDeviceId = deviceId;
2146
+ bindCameraUseCases(); // Rebind with the new, highly specific selector
2147
+ } else {
2148
+ Log.e(
2149
+ TAG,
2150
+ "switchToDevice: Could not find any CameraInfo matching deviceId: " +
2151
+ deviceId
2152
+ );
2153
+ }
2154
+ } catch (Exception e) {
2155
+ Log.e(TAG, "switchToDevice: Error switching camera", e);
2156
+ }
2157
+ });
2158
+ }
2159
+
2160
+ public void flipCamera() {
2161
+ Log.d(TAG, "flipCamera: Flipping camera");
2162
+
2163
+ // Determine current position based on session config and flip it
2164
+ String currentPosition = sessionConfig.getPosition();
2165
+ String newPosition = "front".equals(currentPosition) ? "rear" : "front";
2166
+
2167
+ Log.d(
2168
+ TAG,
2169
+ "flipCamera: Switching from " + currentPosition + " to " + newPosition
2170
+ );
2171
+
2172
+ sessionConfig = new CameraSessionConfiguration(
2173
+ null, // deviceId - clear device ID to force position-based selection
2174
+ newPosition, // position
2175
+ sessionConfig.getX(), // x
2176
+ sessionConfig.getY(), // y
2177
+ sessionConfig.getWidth(), // width
2178
+ sessionConfig.getHeight(), // height
2179
+ sessionConfig.getPaddingBottom(), // paddingBottom
2180
+ sessionConfig.isToBack(), // toBack
2181
+ sessionConfig.isStoreToFile(), // storeToFile
2182
+ sessionConfig.isEnableOpacity(), // enableOpacity
2183
+ sessionConfig.isEnableZoom(), // enableZoom
2184
+ sessionConfig.isDisableExifHeaderStripping(), // disableExifHeaderStripping
2185
+ sessionConfig.isDisableAudio(), // disableAudio
2186
+ sessionConfig.getZoomFactor(), // zoomFactor
2187
+ sessionConfig.getAspectRatio(), // aspectRatio
2188
+ sessionConfig.getGridMode() // gridMode
2189
+ );
2190
+
2191
+ // Clear current device ID to force position-based selection
2192
+ currentDeviceId = null;
2193
+
2194
+ // Camera operations must run on main thread
2195
+ cameraExecutor.execute(() -> {
2196
+ currentCameraSelector = buildCameraSelector();
2197
+ bindCameraUseCases();
2198
+ });
2199
+ }
2200
+
2201
+ public void setOpacity(float opacity) {
2202
+ if (previewView != null) {
2203
+ previewView.setAlpha(opacity);
2204
+ }
2205
+ }
2206
+
2207
+ private void updateLayoutParams() {
2208
+ if (sessionConfig == null) return;
2209
+
2210
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
2211
+ sessionConfig.getWidth(),
2212
+ sessionConfig.getHeight()
2213
+ );
2214
+ layoutParams.leftMargin = sessionConfig.getX();
2215
+ layoutParams.topMargin = sessionConfig.getY();
2216
+
2217
+ if (sessionConfig.getAspectRatio() != null) {
2218
+ String[] ratios = sessionConfig.getAspectRatio().split(":");
2219
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
2220
+ float ratio = Float.parseFloat(ratios[1]) / Float.parseFloat(ratios[0]);
2221
+ if (sessionConfig.getWidth() > 0) {
2222
+ layoutParams.height = (int) (sessionConfig.getWidth() / ratio);
2223
+ } else if (sessionConfig.getHeight() > 0) {
2224
+ layoutParams.width = (int) (sessionConfig.getHeight() * ratio);
2225
+ }
2226
+ }
2227
+
2228
+ previewView.setLayoutParams(layoutParams);
2229
+
2230
+ if (listener != null) {
2231
+ listener.onCameraStarted(
2232
+ sessionConfig.getWidth(),
2233
+ sessionConfig.getHeight(),
2234
+ sessionConfig.getX(),
2235
+ sessionConfig.getY()
2236
+ );
2237
+ }
2238
+ }
2239
+
2240
+ public String getAspectRatio() {
2241
+ if (sessionConfig != null) {
2242
+ return sessionConfig.getAspectRatio();
2243
+ }
2244
+ return "4:3";
2245
+ }
2246
+
2247
+ public String getGridMode() {
2248
+ if (sessionConfig != null) {
2249
+ return sessionConfig.getGridMode();
2250
+ }
2251
+ return "none";
2252
+ }
2253
+
2254
+ public void setAspectRatio(String aspectRatio) {
2255
+ setAspectRatio(aspectRatio, null, null);
2256
+ }
2257
+
2258
+ public void setAspectRatio(String aspectRatio, Float x, Float y) {
2259
+ setAspectRatio(aspectRatio, x, y, null);
2260
+ }
2261
+
2262
+ public void setAspectRatio(
2263
+ String aspectRatio,
2264
+ Float x,
2265
+ Float y,
2266
+ Runnable callback
2267
+ ) {
2268
+ if (sessionConfig == null) {
2269
+ if (callback != null) callback.run();
2270
+ return;
2271
+ }
2272
+
2273
+ String currentAspectRatio = sessionConfig.getAspectRatio();
2274
+
2275
+ // Don't restart camera if aspect ratio hasn't changed and no position specified
2276
+ if (
2277
+ aspectRatio != null &&
2278
+ aspectRatio.equals(currentAspectRatio) &&
2279
+ x == null &&
2280
+ y == null
2281
+ ) {
2282
+ Log.d(
2283
+ TAG,
2284
+ "setAspectRatio: Aspect ratio " +
2285
+ aspectRatio +
2286
+ " is already set and no position specified, skipping"
2287
+ );
2288
+ if (callback != null) callback.run();
2289
+ return;
2290
+ }
2291
+
2292
+ String currentGridMode = sessionConfig.getGridMode();
2293
+ Log.d(
2294
+ TAG,
2295
+ "setAspectRatio: Changing from " +
2296
+ currentAspectRatio +
2297
+ " to " +
2298
+ aspectRatio +
2299
+ (x != null && y != null
2300
+ ? " at position (" + x + ", " + y + ")"
2301
+ : " with auto-centering") +
2302
+ ", preserving grid mode: " +
2303
+ currentGridMode
2304
+ );
2305
+
2306
+ sessionConfig = new CameraSessionConfiguration(
2307
+ sessionConfig.getDeviceId(),
2308
+ sessionConfig.getPosition(),
2309
+ sessionConfig.getX(),
2310
+ sessionConfig.getY(),
2311
+ sessionConfig.getWidth(),
2312
+ sessionConfig.getHeight(),
2313
+ sessionConfig.getPaddingBottom(),
2314
+ sessionConfig.getToBack(),
2315
+ sessionConfig.getStoreToFile(),
2316
+ sessionConfig.getEnableOpacity(),
2317
+ sessionConfig.getEnableZoom(),
2318
+ sessionConfig.getDisableExifHeaderStripping(),
2319
+ sessionConfig.getDisableAudio(),
2320
+ sessionConfig.getZoomFactor(),
2321
+ aspectRatio,
2322
+ currentGridMode
2323
+ );
2324
+
2325
+ // Update layout and rebind camera with new aspect ratio
2326
+ if (isRunning && previewContainer != null) {
2327
+ mainExecutor.execute(() -> {
2328
+ // First update the UI layout
2329
+ updatePreviewLayoutForAspectRatio(aspectRatio, x, y);
2330
+
2331
+ // Then rebind the camera with new aspect ratio configuration
2332
+ Log.d(
2333
+ TAG,
2334
+ "setAspectRatio: Rebinding camera with new aspect ratio: " +
2335
+ aspectRatio
2336
+ );
2337
+ bindCameraUseCases();
2338
+
2339
+ // Preserve grid mode and wait for completion
2340
+ if (gridOverlayView != null) {
2341
+ gridOverlayView.post(() -> {
2342
+ Log.d(
2343
+ TAG,
2344
+ "setAspectRatio: Re-applying grid mode: " + currentGridMode
2345
+ );
2346
+ gridOverlayView.setGridMode(currentGridMode);
2347
+
2348
+ // Wait one more frame for grid to be applied, then call callback
2349
+ if (callback != null) {
2350
+ gridOverlayView.post(callback);
2351
+ }
2352
+ });
2353
+ } else {
2354
+ // No grid overlay, wait one frame for layout completion then call callback
2355
+ if (callback != null) {
2356
+ previewContainer.post(callback);
2357
+ }
2358
+ }
2359
+ });
2360
+ } else {
2361
+ if (callback != null) callback.run();
2362
+ }
2363
+ }
2364
+
2365
+ public void setGridMode(String gridMode) {
2366
+ if (sessionConfig != null) {
2367
+ Log.d(TAG, "setGridMode: Changing grid mode to: " + gridMode);
2368
+ sessionConfig = new CameraSessionConfiguration(
2369
+ sessionConfig.getDeviceId(),
2370
+ sessionConfig.getPosition(),
2371
+ sessionConfig.getX(),
2372
+ sessionConfig.getY(),
2373
+ sessionConfig.getWidth(),
2374
+ sessionConfig.getHeight(),
2375
+ sessionConfig.getPaddingBottom(),
2376
+ sessionConfig.getToBack(),
2377
+ sessionConfig.getStoreToFile(),
2378
+ sessionConfig.getEnableOpacity(),
2379
+ sessionConfig.getEnableZoom(),
2380
+ sessionConfig.getDisableExifHeaderStripping(),
2381
+ sessionConfig.getDisableAudio(),
2382
+ sessionConfig.getZoomFactor(),
2383
+ sessionConfig.getAspectRatio(),
2384
+ gridMode
2385
+ );
2386
+
2387
+ // Update the grid overlay immediately
2388
+ if (gridOverlayView != null) {
2389
+ gridOverlayView.post(() -> {
2390
+ Log.d(TAG, "setGridMode: Applying grid mode to overlay: " + gridMode);
2391
+ gridOverlayView.setGridMode(gridMode);
2392
+ });
2393
+ }
2394
+ }
2395
+ }
2396
+
2397
+ public int getPreviewX() {
2398
+ if (previewContainer == null) return 0;
2399
+
2400
+ // Get the container position
2401
+ ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
2402
+ if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2403
+ int containerX = ((ViewGroup.MarginLayoutParams) layoutParams).leftMargin;
2404
+
2405
+ // Get the actual camera bounds within the container
2406
+ Rect cameraBounds = getActualCameraBounds();
2407
+ int actualX = containerX + cameraBounds.left;
2408
+
2409
+ Log.d(
2410
+ TAG,
2411
+ "getPreviewX: containerX=" +
2412
+ containerX +
2413
+ ", cameraBounds.left=" +
2414
+ cameraBounds.left +
2415
+ ", actualX=" +
2416
+ actualX
2417
+ );
2418
+
2419
+ return actualX;
2420
+ }
2421
+ return previewContainer.getLeft();
2422
+ }
2423
+
2424
+ public int getPreviewY() {
2425
+ if (previewContainer == null) return 0;
2426
+
2427
+ // Get the container position
2428
+ ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
2429
+ if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2430
+ int containerY = ((ViewGroup.MarginLayoutParams) layoutParams).topMargin;
2431
+
2432
+ // Get the actual camera bounds within the container
2433
+ Rect cameraBounds = getActualCameraBounds();
2434
+ int actualY = containerY + cameraBounds.top;
2435
+
2436
+ Log.d(
2437
+ TAG,
2438
+ "getPreviewY: containerY=" +
2439
+ containerY +
2440
+ ", cameraBounds.top=" +
2441
+ cameraBounds.top +
2442
+ ", actualY=" +
2443
+ actualY
2444
+ );
2445
+
2446
+ return actualY;
2447
+ }
2448
+ return previewContainer.getTop();
2449
+ }
2450
+
2451
+ // Get the actual camera content bounds within the PreviewView
2452
+ private Rect getActualCameraBounds() {
2453
+ if (previewView == null || previewContainer == null) {
2454
+ return new Rect(0, 0, 0, 0);
2455
+ }
2456
+
2457
+ // Get the container bounds
2458
+ int containerWidth = previewContainer.getWidth();
2459
+ int containerHeight = previewContainer.getHeight();
2460
+
2461
+ // Get the preview transformation info to understand how the camera is scaled/positioned
2462
+ // For FIT_CENTER, the camera content is scaled to fit within the container
2463
+ // This might create letterboxing (black bars) on top/bottom or left/right
2464
+
2465
+ // Get the actual preview resolution
2466
+ if (currentPreviewResolution == null) {
2467
+ // If we don't have the resolution yet, assume the container is filled
2468
+ return new Rect(0, 0, containerWidth, containerHeight);
2469
+ }
2470
+
2471
+ // The preview is rotated 90 degrees for portrait mode
2472
+ // So we swap the dimensions
2473
+ int cameraWidth = currentPreviewResolution.getHeight();
2474
+ int cameraHeight = currentPreviewResolution.getWidth();
2475
+
2476
+ // Calculate the scaling factor to fit the camera in the container
2477
+ float widthScale = (float) containerWidth / cameraWidth;
2478
+ float heightScale = (float) containerHeight / cameraHeight;
2479
+ float scale = Math.min(widthScale, heightScale); // FIT_CENTER uses min scale
2480
+
2481
+ // Calculate the actual size of the camera content after scaling
2482
+ int scaledWidth = Math.round(cameraWidth * scale);
2483
+ int scaledHeight = Math.round(cameraHeight * scale);
2484
+
2485
+ // Calculate the offset to center the content
2486
+ int offsetX = (containerWidth - scaledWidth) / 2;
2487
+ int offsetY = (containerHeight - scaledHeight) / 2;
2488
+
2489
+ Log.d(
2490
+ TAG,
2491
+ "getActualCameraBounds: container=" +
2492
+ containerWidth +
2493
+ "x" +
2494
+ containerHeight +
2495
+ ", camera=" +
2496
+ cameraWidth +
2497
+ "x" +
2498
+ cameraHeight +
2499
+ ", scale=" +
2500
+ scale +
2501
+ ", scaled=" +
2502
+ scaledWidth +
2503
+ "x" +
2504
+ scaledHeight +
2505
+ ", offset=(" +
2506
+ offsetX +
2507
+ "," +
2508
+ offsetY +
2509
+ ")"
2510
+ );
2511
+
2512
+ // Return the bounds relative to the container
2513
+ return new Rect(
2514
+ offsetX,
2515
+ offsetY,
2516
+ offsetX + scaledWidth,
2517
+ offsetY + scaledHeight
2518
+ );
2519
+ }
2520
+
2521
+ public int getPreviewWidth() {
2522
+ if (previewContainer == null) return 0;
2523
+ Rect bounds = getActualCameraBounds();
2524
+ return bounds.width();
2525
+ }
2526
+
2527
+ public int getPreviewHeight() {
2528
+ if (previewContainer == null) return 0;
2529
+ Rect bounds = getActualCameraBounds();
2530
+ return bounds.height();
2531
+ }
2532
+
2533
+ public void setPreviewSize(int x, int y, int width, int height) {
2534
+ setPreviewSize(x, y, width, height, null);
2535
+ }
2536
+
2537
+ public void setPreviewSize(
2538
+ int x,
2539
+ int y,
2540
+ int width,
2541
+ int height,
2542
+ Runnable callback
2543
+ ) {
2544
+ if (previewContainer == null) {
2545
+ if (callback != null) callback.run();
2546
+ return;
2547
+ }
2548
+
2549
+ // Ensure this runs on the main UI thread
2550
+ mainExecutor.execute(() -> {
2551
+ ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
2552
+ if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2553
+ ViewGroup.MarginLayoutParams params =
2554
+ (ViewGroup.MarginLayoutParams) layoutParams;
2555
+
2556
+ // Only add insets for positioning coordinates, not for full-screen sizes
2557
+ int webViewTopInset = getWebViewTopInset();
2558
+ int webViewLeftInset = getWebViewLeftInset();
2559
+
2560
+ // Handle positioning - preserve current values if new values are not specified (negative)
2561
+ if (x >= 0) {
2562
+ // Don't add insets if this looks like a calculated full-screen coordinate (x=0, y=0)
2563
+ if (x == 0 && y == 0) {
2564
+ params.leftMargin = x;
2565
+ Log.d(
2566
+ TAG,
2567
+ "setPreviewSize: Full-screen mode - keeping x=0 without insets"
2568
+ );
2569
+ } else {
2570
+ params.leftMargin = x + webViewLeftInset;
2571
+ Log.d(
2572
+ TAG,
2573
+ "setPreviewSize: Positioned mode - x=" +
2574
+ x +
2575
+ " + inset=" +
2576
+ webViewLeftInset +
2577
+ " = " +
2578
+ (x + webViewLeftInset)
2579
+ );
2580
+ }
2581
+ }
2582
+ if (y >= 0) {
2583
+ // Don't add insets if this looks like a calculated full-screen coordinate (x=0, y=0)
2584
+ if (x == 0 && y == 0) {
2585
+ params.topMargin = y;
2586
+ Log.d(
2587
+ TAG,
2588
+ "setPreviewSize: Full-screen mode - keeping y=0 without insets"
2589
+ );
2590
+ } else {
2591
+ params.topMargin = y + webViewTopInset;
2592
+ Log.d(
2593
+ TAG,
2594
+ "setPreviewSize: Positioned mode - y=" +
2595
+ y +
2596
+ " + inset=" +
2597
+ webViewTopInset +
2598
+ " = " +
2599
+ (y + webViewTopInset)
2600
+ );
2601
+ }
2602
+ }
2603
+ if (width > 0) params.width = width;
2604
+ if (height > 0) params.height = height;
2605
+
2606
+ previewContainer.setLayoutParams(params);
2607
+ previewContainer.requestLayout();
2608
+
2609
+ Log.d(
2610
+ TAG,
2611
+ "setPreviewSize: Updated to " +
2612
+ params.width +
2613
+ "x" +
2614
+ params.height +
2615
+ " at (" +
2616
+ params.leftMargin +
2617
+ "," +
2618
+ params.topMargin +
2619
+ ")"
2620
+ );
2621
+
2622
+ // Update session config to reflect actual layout
2623
+ if (sessionConfig != null) {
2624
+ String currentAspectRatio = sessionConfig.getAspectRatio();
2625
+
2626
+ // Calculate aspect ratio from actual dimensions if both width and height are provided
2627
+ String calculatedAspectRatio = currentAspectRatio;
2628
+ if (params.width > 0 && params.height > 0) {
2629
+ // Always use larger dimension / smaller dimension for consistent comparison
2630
+ float ratio =
2631
+ Math.max(params.width, params.height) /
2632
+ (float) Math.min(params.width, params.height);
2633
+ // Standard ratios: 16:9 ≈ 1.778, 4:3 ≈ 1.333
2634
+ float ratio16_9 = 16f / 9f; // 1.778
2635
+ float ratio4_3 = 4f / 3f; // 1.333
2636
+
2637
+ // Determine closest standard aspect ratio
2638
+ if (Math.abs(ratio - ratio16_9) < Math.abs(ratio - ratio4_3)) {
2639
+ calculatedAspectRatio = "16:9";
2640
+ } else {
2641
+ calculatedAspectRatio = "4:3";
2642
+ }
2643
+ Log.d(
2644
+ TAG,
2645
+ "setPreviewSize: Calculated aspect ratio from " +
2646
+ params.width +
2647
+ "x" +
2648
+ params.height +
2649
+ " = " +
2650
+ calculatedAspectRatio +
2651
+ " (normalized ratio=" +
2652
+ ratio +
2653
+ ")"
2654
+ );
2655
+ }
2656
+
2657
+ sessionConfig = new CameraSessionConfiguration(
2658
+ sessionConfig.getDeviceId(),
2659
+ sessionConfig.getPosition(),
2660
+ params.leftMargin,
2661
+ params.topMargin,
2662
+ params.width,
2663
+ params.height,
2664
+ sessionConfig.getPaddingBottom(),
2665
+ sessionConfig.getToBack(),
2666
+ sessionConfig.getStoreToFile(),
2667
+ sessionConfig.getEnableOpacity(),
2668
+ sessionConfig.getEnableZoom(),
2669
+ sessionConfig.getDisableExifHeaderStripping(),
2670
+ sessionConfig.getDisableAudio(),
2671
+ sessionConfig.getZoomFactor(),
2672
+ calculatedAspectRatio,
2673
+ sessionConfig.getGridMode()
2674
+ );
2675
+
2676
+ // If aspect ratio changed due to size update, rebind camera
2677
+ if (
2678
+ isRunning &&
2679
+ !Objects.equals(currentAspectRatio, calculatedAspectRatio)
2680
+ ) {
2681
+ Log.d(
2682
+ TAG,
2683
+ "setPreviewSize: Aspect ratio changed from " +
2684
+ currentAspectRatio +
2685
+ " to " +
2686
+ calculatedAspectRatio +
2687
+ ", rebinding camera"
2688
+ );
2689
+ bindCameraUseCases();
2690
+
2691
+ // Wait for camera rebinding to complete, then call callback
2692
+ if (callback != null) {
2693
+ previewContainer.post(() -> {
2694
+ updateGridOverlayBounds();
2695
+ previewContainer.post(callback);
2696
+ });
2697
+ } else {
2698
+ previewContainer.post(() -> updateGridOverlayBounds());
2699
+ }
2700
+ } else {
2701
+ // No camera rebinding needed, wait for layout to complete then call callback
2702
+ previewContainer.post(() -> {
2703
+ updateGridOverlayBounds();
2704
+ if (callback != null) {
2705
+ callback.run();
2706
+ }
2707
+ });
2708
+ }
2709
+ } else {
2710
+ // No sessionConfig, just wait for layout then call callback
2711
+ previewContainer.post(() -> {
2712
+ updateGridOverlayBounds();
2713
+ if (callback != null) {
2714
+ callback.run();
2715
+ }
2716
+ });
2717
+ }
2718
+ } else {
2719
+ Log.w(
2720
+ TAG,
2721
+ "setPreviewSize: Cannot set margins on layout params of type " +
2722
+ layoutParams.getClass().getSimpleName()
2723
+ );
2724
+ // Fallback: just set width and height if specified
2725
+ if (width > 0) layoutParams.width = width;
2726
+ if (height > 0) layoutParams.height = height;
2727
+ previewContainer.setLayoutParams(layoutParams);
2728
+ previewContainer.requestLayout();
2729
+
2730
+ // Wait for layout then call callback
2731
+ if (callback != null) {
2732
+ previewContainer.post(callback);
2733
+ }
2734
+ }
2735
+ });
2736
+ }
2737
+
2738
+ private void updatePreviewLayoutForAspectRatio(String aspectRatio) {
2739
+ updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
2740
+ }
2741
+
2742
+ private void updatePreviewLayoutForAspectRatio(
2743
+ String aspectRatio,
2744
+ Float x,
2745
+ Float y
2746
+ ) {
2747
+ if (previewContainer == null || aspectRatio == null) return;
2748
+
2749
+ // Parse aspect ratio
2750
+ String[] ratios = aspectRatio.split(":");
2751
+ if (ratios.length != 2) return;
2752
+
2753
+ try {
2754
+ // For camera, use portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
2755
+ float ratio = Float.parseFloat(ratios[1]) / Float.parseFloat(ratios[0]);
2756
+
2757
+ // Get available space from webview dimensions
2758
+ int availableWidth = webView.getWidth();
2759
+ int availableHeight = webView.getHeight();
2760
+
2761
+ // Calculate position and size
2762
+ int finalX, finalY, finalWidth, finalHeight;
2763
+
2764
+ if (x != null && y != null) {
2765
+ // Account for WebView insets from edge-to-edge support
2766
+ int webViewTopInset = getWebViewTopInset();
2767
+ int webViewLeftInset = getWebViewLeftInset();
2768
+
2769
+ // Use provided coordinates with boundary checking, adjusted for insets
2770
+ finalX = Math.max(
2771
+ 0,
2772
+ Math.min(x.intValue() + webViewLeftInset, availableWidth)
2773
+ );
2774
+ finalY = Math.max(
2775
+ 0,
2776
+ Math.min(y.intValue() + webViewTopInset, availableHeight)
2777
+ );
2778
+
2779
+ // Calculate maximum available space from the given position
2780
+ int maxWidth = availableWidth - finalX;
2781
+ int maxHeight = availableHeight - finalY;
2782
+
2783
+ // Calculate optimal size while maintaining aspect ratio within available space
2784
+ finalWidth = maxWidth;
2785
+ finalHeight = (int) (maxWidth / ratio);
2786
+
2787
+ if (finalHeight > maxHeight) {
2788
+ // Height constraint is tighter, fit by height
2789
+ finalHeight = maxHeight;
2790
+ finalWidth = (int) (maxHeight * ratio);
2791
+ }
2792
+
2793
+ // Ensure final position stays within bounds
2794
+ finalX = Math.max(0, Math.min(finalX, availableWidth - finalWidth));
2795
+ finalY = Math.max(0, Math.min(finalY, availableHeight - finalHeight));
2796
+ } else {
2797
+ // Auto-center the view
2798
+ // Use full available space to match iOS behavior
2799
+ int maxAvailableWidth = availableWidth;
2800
+ int maxAvailableHeight = availableHeight;
2801
+
2802
+ // Start with width-based calculation
2803
+ finalWidth = maxAvailableWidth;
2804
+ finalHeight = (int) (finalWidth / ratio);
2805
+
2806
+ // If height exceeds available space, use height-based calculation
2807
+ if (finalHeight > maxAvailableHeight) {
2808
+ finalHeight = maxAvailableHeight;
2809
+ finalWidth = (int) (finalHeight * ratio);
2810
+ }
2811
+
2812
+ // Center the view
2813
+ finalX = (availableWidth - finalWidth) / 2;
2814
+ finalY = (availableHeight - finalHeight) / 2;
2815
+
2816
+ Log.d(
2817
+ TAG,
2818
+ "updatePreviewLayoutForAspectRatio: Auto-center mode - ratio=" +
2819
+ ratio +
2820
+ ", calculated size=" +
2821
+ finalWidth +
2822
+ "x" +
2823
+ finalHeight +
2824
+ ", available=" +
2825
+ availableWidth +
2826
+ "x" +
2827
+ availableHeight
2828
+ );
2829
+ }
2830
+
2831
+ // Update layout params
2832
+ ViewGroup.LayoutParams currentParams = previewContainer.getLayoutParams();
2833
+ if (currentParams instanceof ViewGroup.MarginLayoutParams) {
2834
+ ViewGroup.MarginLayoutParams params =
2835
+ (ViewGroup.MarginLayoutParams) currentParams;
2836
+ params.width = finalWidth;
2837
+ params.height = finalHeight;
2838
+ params.leftMargin = finalX;
2839
+ params.topMargin = finalY;
2840
+ previewContainer.setLayoutParams(params);
2841
+ previewContainer.requestLayout();
2842
+ Log.d(
2843
+ TAG,
2844
+ "updatePreviewLayoutForAspectRatio: Updated to " +
2845
+ finalWidth +
2846
+ "x" +
2847
+ finalHeight +
2848
+ " at (" +
2849
+ finalX +
2850
+ "," +
2851
+ finalY +
2852
+ ")"
2853
+ );
2854
+
2855
+ // Update grid overlay bounds after aspect ratio change
2856
+ previewContainer.post(() -> updateGridOverlayBounds());
2857
+ }
2858
+ } catch (NumberFormatException e) {
2859
+ Log.e(TAG, "Invalid aspect ratio format: " + aspectRatio, e);
2860
+ }
2861
+ }
2862
+
2863
+ private int getWebViewTopInset() {
2864
+ try {
2865
+ if (webView != null) {
2866
+ // Get the actual WebView position on screen
2867
+ int[] location = new int[2];
2868
+ webView.getLocationOnScreen(location);
2869
+ return location[1]; // Y position is the top inset
2870
+ }
2871
+ } catch (Exception e) {
2872
+ Log.w(TAG, "Failed to get WebView top inset", e);
2873
+ }
2874
+ return 0;
2875
+ }
2876
+
2877
+ private int getWebViewLeftInset() {
2878
+ try {
2879
+ if (webView != null) {
2880
+ // Get the actual WebView position on screen for consistency
2881
+ int[] location = new int[2];
2882
+ webView.getLocationOnScreen(location);
2883
+ return location[0]; // X position is the left inset
2884
+ }
2885
+ } catch (Exception e) {
2886
+ Log.w(TAG, "Failed to get WebView left inset", e);
2887
+ }
2888
+ return 0;
2889
+ }
2890
+
2891
+ /**
2892
+ * Get the current preview position and size in DP units (without insets)
2893
+ */
2894
+ public int[] getCurrentPreviewBounds() {
2895
+ if (previewContainer == null) {
2896
+ return new int[] { 0, 0, 0, 0 }; // x, y, width, height
2897
+ }
2898
+
2899
+ // Get actual camera preview bounds (accounts for letterboxing/pillarboxing)
2900
+ int actualX = getPreviewX();
2901
+ int actualY = getPreviewY();
2902
+ int actualWidth = getPreviewWidth();
2903
+ int actualHeight = getPreviewHeight();
2904
+
2905
+ // Convert to logical pixels for JavaScript
2906
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
2907
+ float pixelRatio = metrics.density;
2908
+
2909
+ // Remove WebView insets from coordinates
2910
+ int webViewTopInset = getWebViewTopInset();
2911
+ int webViewLeftInset = getWebViewLeftInset();
2912
+
2913
+ int x = Math.max(0, (int) ((actualX - webViewLeftInset) / pixelRatio));
2914
+ int y = Math.max(0, (int) ((actualY - webViewTopInset) / pixelRatio));
2915
+ int width = (int) (actualWidth / pixelRatio);
2916
+ int height = (int) (actualHeight / pixelRatio);
2917
+
2918
+ return new int[] { x, y, width, height };
2919
+ }
2920
+
2921
+ private void updateGridOverlayBounds() {
2922
+ if (gridOverlayView != null && previewView != null) {
2923
+ // Get the actual camera bounds
2924
+ Rect cameraBounds = getActualCameraBounds();
2925
+
2926
+ // Update the grid overlay with the camera bounds
2927
+ gridOverlayView.setCameraBounds(cameraBounds);
2928
+
2929
+ Log.d(
2930
+ TAG,
2931
+ "updateGridOverlayBounds: Updated grid bounds to " +
2932
+ cameraBounds.toString()
2933
+ );
2934
+ }
2935
+ }
2936
+
2937
+ private void triggerAutoFocus() {
2938
+ if (camera == null) {
2939
+ return;
2940
+ }
2941
+
2942
+ Log.d(TAG, "triggerAutoFocus: Triggering autofocus at center");
2943
+
2944
+ // Cancel any ongoing focus operation
2945
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
2946
+ Log.d(TAG, "triggerAutoFocus: Cancelling previous focus operation");
2947
+ currentFocusFuture.cancel(true);
2948
+ }
2949
+
2950
+ // Focus on the center of the view
2951
+ int viewWidth = previewView.getWidth();
2952
+ int viewHeight = previewView.getHeight();
2953
+
2954
+ if (viewWidth == 0 || viewHeight == 0) {
2955
+ return;
2956
+ }
2957
+
2958
+ // Create MeteringPoint at the center of the preview
2959
+ MeteringPointFactory factory = previewView.getMeteringPointFactory();
2960
+ MeteringPoint point = factory.createPoint(viewWidth / 2f, viewHeight / 2f);
2961
+
2962
+ // Create focus and metering action
2963
+ FocusMeteringAction action = new FocusMeteringAction.Builder(
2964
+ point,
2965
+ FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE
2966
+ )
2967
+ .setAutoCancelDuration(3, TimeUnit.SECONDS) // Auto-cancel after 3 seconds
2968
+ .build();
2969
+
2970
+ try {
2971
+ currentFocusFuture = camera
2972
+ .getCameraControl()
2973
+ .startFocusAndMetering(action);
2974
+ currentFocusFuture.addListener(
2975
+ () -> {
2976
+ try {
2977
+ FocusMeteringResult result = currentFocusFuture.get();
2978
+ Log.d(
2979
+ TAG,
2980
+ "triggerAutoFocus: Focus completed successfully: " +
2981
+ result.isFocusSuccessful()
2982
+ );
2983
+ } catch (Exception e) {
2984
+ // Handle cancellation gracefully - this is expected when rapid operations occur
2985
+ if (
2986
+ e.getMessage() != null &&
2987
+ (e
2988
+ .getMessage()
2989
+ .contains("Cancelled by another startFocusAndMetering") ||
2990
+ e.getMessage().contains("OperationCanceledException") ||
2991
+ e
2992
+ .getClass()
2993
+ .getSimpleName()
2994
+ .contains("OperationCanceledException"))
2995
+ ) {
2996
+ Log.d(
2997
+ TAG,
2998
+ "triggerAutoFocus: Auto-focus was cancelled by a newer focus request"
2999
+ );
3000
+ } else {
3001
+ Log.e(TAG, "triggerAutoFocus: Error during focus", e);
3002
+ }
3003
+ } finally {
3004
+ // Clear the reference if this is still the current operation
3005
+ if (currentFocusFuture != null && currentFocusFuture.isDone()) {
3006
+ currentFocusFuture = null;
3007
+ }
3008
+ }
3009
+ },
3010
+ ContextCompat.getMainExecutor(context)
3011
+ );
3012
+ } catch (Exception e) {
3013
+ currentFocusFuture = null;
3014
+ Log.e(TAG, "triggerAutoFocus: Failed to trigger autofocus", e);
3015
+ }
3016
+ }
3017
+ }