@capgo/camera-preview 7.4.0-beta.2 → 7.4.0-beta.20

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