@capgo/camera-preview 7.4.0-beta.13 → 7.4.0-beta.16

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.
@@ -1,24 +1,31 @@
1
1
  package com.ahm.capacitor.camera.preview;
2
2
 
3
3
  import android.content.Context;
4
- import android.content.Intent;
5
4
  import android.graphics.Bitmap;
6
5
  import android.graphics.BitmapFactory;
7
- import android.graphics.Matrix;
6
+ import android.graphics.Color;
7
+ import android.graphics.Rect;
8
+ import android.graphics.drawable.GradientDrawable;
8
9
  import android.hardware.camera2.CameraAccessException;
9
10
  import android.hardware.camera2.CameraCharacteristics;
10
11
  import android.hardware.camera2.CameraManager;
11
12
  import android.location.Location;
12
- import android.net.Uri;
13
+ import android.media.MediaScannerConnection;
13
14
  import android.os.Build;
14
15
  import android.os.Environment;
15
16
  import android.util.Base64;
16
17
  import android.util.DisplayMetrics;
17
18
  import android.util.Log;
18
- import android.util.Rational;
19
19
  import android.util.Size;
20
+ import android.view.MotionEvent;
21
+ import android.view.View;
20
22
  import android.view.ViewGroup;
21
- 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;
22
29
  import android.webkit.WebView;
23
30
  import android.widget.FrameLayout;
24
31
  import android.widget.FrameLayout;
@@ -31,15 +38,11 @@ import androidx.camera.core.Camera;
31
38
  import androidx.camera.core.CameraInfo;
32
39
  import androidx.camera.core.CameraSelector;
33
40
  import androidx.camera.core.FocusMeteringAction;
34
- import androidx.camera.core.FocusMeteringAction;
35
- import androidx.camera.core.FocusMeteringResult;
36
41
  import androidx.camera.core.FocusMeteringResult;
37
42
  import androidx.camera.core.ImageCapture;
38
43
  import androidx.camera.core.ImageCaptureException;
39
44
  import androidx.camera.core.ImageProxy;
40
45
  import androidx.camera.core.MeteringPoint;
41
- import androidx.camera.core.MeteringPoint;
42
- import androidx.camera.core.MeteringPointFactory;
43
46
  import androidx.camera.core.MeteringPointFactory;
44
47
  import androidx.camera.core.Preview;
45
48
  import androidx.camera.core.ResolutionInfo;
@@ -55,7 +58,6 @@ import androidx.lifecycle.Lifecycle;
55
58
  import androidx.lifecycle.LifecycleObserver;
56
59
  import androidx.lifecycle.LifecycleOwner;
57
60
  import androidx.lifecycle.LifecycleRegistry;
58
- import androidx.lifecycle.OnLifecycleEvent;
59
61
  import com.ahm.capacitor.camera.preview.model.CameraSessionConfiguration;
60
62
  import com.ahm.capacitor.camera.preview.model.LensInfo;
61
63
  import com.ahm.capacitor.camera.preview.model.ZoomFactors;
@@ -65,7 +67,6 @@ import java.io.File;
65
67
  import java.io.FileOutputStream;
66
68
  import java.io.IOException;
67
69
  import java.nio.ByteBuffer;
68
- import java.nio.file.Files;
69
70
  import java.text.SimpleDateFormat;
70
71
  import java.util.ArrayList;
71
72
  import java.util.Arrays;
@@ -100,6 +101,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
100
101
  private PreviewView previewView;
101
102
  private GridOverlayView gridOverlayView;
102
103
  private FrameLayout previewContainer;
104
+ private View focusIndicatorView;
103
105
  private CameraSelector currentCameraSelector;
104
106
  private String currentDeviceId;
105
107
  private int currentFlashMode = ImageCapture.FLASH_MODE_OFF;
@@ -111,6 +113,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
111
113
  private final Executor mainExecutor;
112
114
  private ExecutorService cameraExecutor;
113
115
  private boolean isRunning = false;
116
+ private Size currentPreviewResolution = null;
117
+ private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
114
118
 
115
119
  public CameraXView(Context context, WebView webView) {
116
120
  this.context = context;
@@ -139,6 +143,47 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
139
143
 
140
144
  private void saveImageToGallery(byte[] data) {
141
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
+
142
187
  File photo = new File(
143
188
  Environment.getExternalStoragePublicDirectory(
144
189
  Environment.DIRECTORY_PICTURES
@@ -147,19 +192,19 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
147
192
  new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
148
193
  new java.util.Date()
149
194
  ) +
150
- ".jpg"
195
+ extension
151
196
  );
152
197
  FileOutputStream fos = new FileOutputStream(photo);
153
198
  fos.write(data);
154
199
  fos.close();
155
200
 
156
201
  // Notify the gallery of the new image
157
- Intent mediaScanIntent = new Intent(
158
- Intent.ACTION_MEDIA_SCANNER_SCAN_FILE
202
+ MediaScannerConnection.scanFile(
203
+ this.context,
204
+ new String[] { photo.getAbsolutePath() },
205
+ new String[] { mimeType },
206
+ null
159
207
  );
160
- Uri contentUri = Uri.fromFile(photo);
161
- mediaScanIntent.setData(contentUri);
162
- context.sendBroadcast(mediaScanIntent);
163
208
  } catch (IOException e) {
164
209
  Log.e(TAG, "Error saving image to gallery", e);
165
210
  }
@@ -176,6 +221,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
176
221
 
177
222
  public void stopSession() {
178
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
+
179
230
  mainExecutor.execute(() -> {
180
231
  if (cameraProvider != null) {
181
232
  cameraProvider.unbindAll();
@@ -219,10 +270,70 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
219
270
 
220
271
  // Create a container to hold both the preview and grid overlay
221
272
  previewContainer = new FrameLayout(context);
273
+ // Ensure container can receive touch events
274
+ previewContainer.setClickable(true);
275
+ previewContainer.setFocusable(true);
222
276
 
223
277
  // Create and setup the preview view
224
278
  previewView = new PreviewView(context);
225
279
  previewView.setScaleType(PreviewView.ScaleType.FIT_CENTER);
280
+ // Also make preview view touchable as backup
281
+ previewView.setClickable(true);
282
+ previewView.setFocusable(true);
283
+
284
+ // Add touch listener to both container and preview view for maximum compatibility
285
+ View.OnTouchListener touchListener = new View.OnTouchListener() {
286
+ @Override
287
+ public boolean onTouch(View v, MotionEvent event) {
288
+ Log.d(
289
+ TAG,
290
+ "onTouch: " +
291
+ v.getClass().getSimpleName() +
292
+ " received touch event: " +
293
+ event.getAction() +
294
+ " at (" +
295
+ event.getX() +
296
+ ", " +
297
+ event.getY() +
298
+ ")"
299
+ );
300
+
301
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
302
+ float x = event.getX() / v.getWidth();
303
+ float y = event.getY() / v.getHeight();
304
+
305
+ Log.d(
306
+ TAG,
307
+ "onTouch: Touch detected at raw coords (" +
308
+ event.getX() +
309
+ ", " +
310
+ event.getY() +
311
+ "), view size: " +
312
+ v.getWidth() +
313
+ "x" +
314
+ v.getHeight() +
315
+ ", normalized: (" +
316
+ x +
317
+ ", " +
318
+ y +
319
+ ")"
320
+ );
321
+
322
+ try {
323
+ // Trigger focus with indicator
324
+ setFocus(x, y);
325
+ } catch (Exception e) {
326
+ Log.e(TAG, "Error during tap-to-focus: " + e.getMessage(), e);
327
+ }
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+ };
333
+
334
+ previewContainer.setOnTouchListener(touchListener);
335
+ previewView.setOnTouchListener(touchListener);
336
+
226
337
  previewContainer.addView(
227
338
  previewView,
228
339
  new FrameLayout.LayoutParams(
@@ -233,6 +344,9 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
233
344
 
234
345
  // Create and setup the grid overlay
235
346
  gridOverlayView = new GridOverlayView(context);
347
+ // Make grid overlay not intercept touch events
348
+ gridOverlayView.setClickable(false);
349
+ gridOverlayView.setFocusable(false);
236
350
  previewContainer.addView(
237
351
  gridOverlayView,
238
352
  new FrameLayout.LayoutParams(
@@ -252,6 +366,47 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
252
366
  FrameLayout.LayoutParams layoutParams = calculatePreviewLayoutParams();
253
367
  parent.addView(previewContainer, layoutParams);
254
368
  if (sessionConfig.isToBack()) webView.bringToFront();
369
+
370
+ // Log the actual position after layout
371
+ previewContainer.post(() -> {
372
+ Log.d(TAG, "========================");
373
+ Log.d(TAG, "ACTUAL CAMERA VIEW POSITION (after layout):");
374
+ Log.d(
375
+ TAG,
376
+ "Container position - Left: " +
377
+ previewContainer.getLeft() +
378
+ ", Top: " +
379
+ previewContainer.getTop() +
380
+ ", Right: " +
381
+ previewContainer.getRight() +
382
+ ", Bottom: " +
383
+ previewContainer.getBottom()
384
+ );
385
+ Log.d(
386
+ TAG,
387
+ "Container size - Width: " +
388
+ previewContainer.getWidth() +
389
+ ", Height: " +
390
+ previewContainer.getHeight()
391
+ );
392
+
393
+ // Get parent info
394
+ ViewGroup containerParent = (ViewGroup) previewContainer.getParent();
395
+ if (containerParent != null) {
396
+ Log.d(
397
+ TAG,
398
+ "Parent class: " + containerParent.getClass().getSimpleName()
399
+ );
400
+ Log.d(
401
+ TAG,
402
+ "Parent size - Width: " +
403
+ containerParent.getWidth() +
404
+ ", Height: " +
405
+ containerParent.getHeight()
406
+ );
407
+ }
408
+ Log.d(TAG, "========================");
409
+ });
255
410
  }
256
411
  }
257
412
 
@@ -296,8 +451,52 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
296
451
  optimalWidth = (int) (height * ratio);
297
452
  }
298
453
 
454
+ // Store the old dimensions to check if we need to recenter
455
+ int oldWidth = width;
456
+ int oldHeight = height;
299
457
  width = optimalWidth;
300
458
  height = optimalHeight;
459
+
460
+ // If we're centered and dimensions changed, recalculate position
461
+ if (sessionConfig.isCentered()) {
462
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
463
+
464
+ if (width != oldWidth) {
465
+ int screenWidth = metrics.widthPixels;
466
+ x = (screenWidth - width) / 2;
467
+ Log.d(
468
+ TAG,
469
+ "calculatePreviewLayoutParams: Recentered X after aspect ratio - " +
470
+ "oldWidth=" +
471
+ oldWidth +
472
+ ", newWidth=" +
473
+ width +
474
+ ", screenWidth=" +
475
+ screenWidth +
476
+ ", newX=" +
477
+ x
478
+ );
479
+ }
480
+
481
+ if (height != oldHeight) {
482
+ int screenHeight = metrics.heightPixels;
483
+ // Always center based on full screen height
484
+ y = (screenHeight - height) / 2;
485
+ Log.d(
486
+ TAG,
487
+ "calculatePreviewLayoutParams: Recentered Y after aspect ratio - " +
488
+ "oldHeight=" +
489
+ oldHeight +
490
+ ", newHeight=" +
491
+ height +
492
+ ", screenHeight=" +
493
+ screenHeight +
494
+ ", newY=" +
495
+ y
496
+ );
497
+ }
498
+ }
499
+
301
500
  Log.d(
302
501
  TAG,
303
502
  "calculatePreviewLayoutParams: Applied aspect ratio " +
@@ -318,41 +517,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
318
517
  height
319
518
  );
320
519
 
321
- // Only add insets for positioning coordinates, not for full-screen sizes
322
- int webViewTopInset = getWebViewTopInset();
323
- int webViewLeftInset = getWebViewLeftInset();
324
-
325
- // Don't add insets if this looks like a calculated full-screen coordinate (x=0, y=0)
326
- if (x == 0 && y == 0) {
327
- layoutParams.leftMargin = x;
328
- layoutParams.topMargin = y;
329
- Log.d(
330
- TAG,
331
- "calculatePreviewLayoutParams: Full-screen mode - keeping position (0,0) without insets"
332
- );
333
- } else {
334
- layoutParams.leftMargin = x + webViewLeftInset;
335
- layoutParams.topMargin = y + webViewTopInset;
336
- Log.d(
337
- TAG,
338
- "calculatePreviewLayoutParams: Positioned mode - applying insets"
339
- );
340
- }
520
+ // The X and Y positions passed from CameraPreview already include webView insets
521
+ // when edge-to-edge is active, so we don't need to add them again here
522
+ layoutParams.leftMargin = x;
523
+ layoutParams.topMargin = y;
341
524
 
342
525
  Log.d(
343
526
  TAG,
344
- "calculatePreviewLayoutParams: Applied insets - x:" +
527
+ "calculatePreviewLayoutParams: Position calculation - x:" +
345
528
  x +
346
- "+" +
347
- webViewLeftInset +
348
- "=" +
529
+ " (leftMargin=" +
349
530
  layoutParams.leftMargin +
350
- ", y:" +
531
+ "), y:" +
351
532
  y +
352
- "+" +
353
- webViewTopInset +
354
- "=" +
355
- layoutParams.topMargin
533
+ " (topMargin=" +
534
+ layoutParams.topMargin +
535
+ ")"
356
536
  );
357
537
 
358
538
  Log.d(
@@ -383,6 +563,9 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
383
563
  if (gridOverlayView != null) {
384
564
  gridOverlayView = null;
385
565
  }
566
+ if (focusIndicatorView != null) {
567
+ focusIndicatorView = null;
568
+ }
386
569
  webView.setBackgroundColor(android.graphics.Color.WHITE);
387
570
  }
388
571
 
@@ -487,10 +670,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
487
670
  // Log resolution info
488
671
  ResolutionInfo previewResolution = preview.getResolutionInfo();
489
672
  if (previewResolution != null) {
490
- Log.d(
491
- TAG,
492
- "Preview resolution: " + previewResolution.getResolution()
493
- );
673
+ currentPreviewResolution = previewResolution.getResolution();
674
+ Log.d(TAG, "Preview resolution: " + currentPreviewResolution);
494
675
  }
495
676
  ResolutionInfo imageCaptureResolution =
496
677
  imageCapture.getResolutionInfo();
@@ -508,6 +689,28 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
508
689
  : sessionConfig.getZoomFactor();
509
690
  if (initialZoom != 1.0f) {
510
691
  Log.d(TAG, "Applying initial zoom of " + initialZoom);
692
+
693
+ // Validate zoom is within bounds
694
+ if (zoomState != null) {
695
+ float minZoom = zoomState.getMinZoomRatio();
696
+ float maxZoom = zoomState.getMaxZoomRatio();
697
+
698
+ if (initialZoom < minZoom || initialZoom > maxZoom) {
699
+ if (listener != null) {
700
+ listener.onCameraStartError(
701
+ "Initial zoom level " +
702
+ initialZoom +
703
+ " is not available. " +
704
+ "Valid range is " +
705
+ minZoom +
706
+ " to " +
707
+ maxZoom
708
+ );
709
+ return;
710
+ }
711
+ }
712
+ }
713
+
511
714
  setZoomInternal(initialZoom);
512
715
  }
513
716
 
@@ -517,18 +720,24 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
517
720
  // Post the callback to ensure layout is complete
518
721
  previewContainer.post(() -> {
519
722
  // Return actual preview container dimensions instead of requested dimensions
520
- int actualWidth = previewContainer != null
521
- ? previewContainer.getWidth()
522
- : sessionConfig.getWidth();
523
- int actualHeight = previewContainer != null
524
- ? previewContainer.getHeight()
525
- : sessionConfig.getHeight();
526
- int actualX = previewContainer != null
527
- ? previewContainer.getLeft()
528
- : sessionConfig.getX();
529
- int actualY = previewContainer != null
530
- ? previewContainer.getTop()
531
- : sessionConfig.getY();
723
+ // Get the actual camera dimensions and position
724
+ int actualWidth = getPreviewWidth();
725
+ int actualHeight = getPreviewHeight();
726
+ int actualX = getPreviewX();
727
+ int actualY = getPreviewY();
728
+
729
+ Log.d(
730
+ TAG,
731
+ "onCameraStarted callback - actualX=" +
732
+ actualX +
733
+ ", actualY=" +
734
+ actualY +
735
+ ", actualWidth=" +
736
+ actualWidth +
737
+ ", actualHeight=" +
738
+ actualHeight
739
+ );
740
+
532
741
  listener.onCameraStarted(
533
742
  actualWidth,
534
743
  actualHeight,
@@ -615,9 +824,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
615
824
  final boolean saveToGallery,
616
825
  Integer width,
617
826
  Integer height,
827
+ String aspectRatio,
618
828
  Location location
619
829
  ) {
620
- Log.d(TAG, "capturePhoto: Starting photo capture with quality: " + quality);
830
+ Log.d(TAG, "capturePhoto: Starting photo capture with quality: " + quality + ", aspectRatio: " + aspectRatio);
831
+
832
+ // Check for conflicting parameters
833
+ if (aspectRatio != null && (width != null || height != null)) {
834
+ if (listener != null) {
835
+ listener.onPictureTakenError("Cannot set both aspectRatio and size (width/height). Use setPreviewSize after start.");
836
+ }
837
+ return;
838
+ }
621
839
 
622
840
  if (imageCapture == null) {
623
841
  if (listener != null) {
@@ -665,7 +883,63 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
665
883
 
666
884
  JSONObject exifData = getExifData(exifInterface);
667
885
 
668
- if (width != null && height != null) {
886
+ // Handle aspect ratio if no width/height specified
887
+ if (width == null && height == null && aspectRatio != null && !aspectRatio.isEmpty()) {
888
+ // Get the original image dimensions
889
+ Bitmap originalBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
890
+ int originalWidth = originalBitmap.getWidth();
891
+ int originalHeight = originalBitmap.getHeight();
892
+
893
+ // Parse aspect ratio
894
+ String[] ratios = aspectRatio.split(":");
895
+ if (ratios.length == 2) {
896
+ try {
897
+ float widthRatio = Float.parseFloat(ratios[0]);
898
+ float heightRatio = Float.parseFloat(ratios[1]);
899
+
900
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
901
+ boolean isPortrait = originalHeight > originalWidth;
902
+ float targetAspectRatio = isPortrait ? heightRatio / widthRatio : widthRatio / heightRatio;
903
+ float originalAspectRatio = (float) originalWidth / originalHeight;
904
+
905
+ int targetWidth, targetHeight;
906
+
907
+ if (originalAspectRatio > targetAspectRatio) {
908
+ // Original is wider than target - fit by height
909
+ targetHeight = originalHeight;
910
+ targetWidth = (int) (targetHeight * targetAspectRatio);
911
+ } else {
912
+ // Original is taller than target - fit by width
913
+ targetWidth = originalWidth;
914
+ targetHeight = (int) (targetWidth / targetAspectRatio);
915
+ }
916
+
917
+ // Center crop the image
918
+ int xOffset = (originalWidth - targetWidth) / 2;
919
+ int yOffset = (originalHeight - targetHeight) / 2;
920
+
921
+ Bitmap croppedBitmap = Bitmap.createBitmap(
922
+ originalBitmap,
923
+ xOffset,
924
+ yOffset,
925
+ targetWidth,
926
+ targetHeight
927
+ );
928
+
929
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
930
+ croppedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream);
931
+ bytes = stream.toByteArray();
932
+
933
+ // Write EXIF data back to cropped image
934
+ bytes = writeExifToImageBytes(bytes, exifInterface);
935
+
936
+ originalBitmap.recycle();
937
+ croppedBitmap.recycle();
938
+ } catch (NumberFormatException e) {
939
+ Log.e(TAG, "Invalid aspect ratio format: " + aspectRatio, e);
940
+ }
941
+ }
942
+ } else if (width != null && height != null) {
669
943
  Bitmap bitmap = BitmapFactory.decodeByteArray(
670
944
  bytes,
671
945
  0,
@@ -1262,7 +1536,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1262
1536
  }
1263
1537
  }
1264
1538
 
1265
- public void setZoom(float zoomRatio) throws Exception {
1539
+ public void setZoom(float zoomRatio, boolean autoFocus) throws Exception {
1266
1540
  if (camera == null) {
1267
1541
  throw new Exception("Camera not initialized");
1268
1542
  }
@@ -1281,6 +1555,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1281
1555
  try {
1282
1556
  zoomFuture.get();
1283
1557
  Log.d(TAG, "Zoom successfully set to " + zoomRatio);
1558
+ // Trigger autofocus after zoom if requested
1559
+ if (autoFocus) {
1560
+ triggerAutoFocus();
1561
+ }
1284
1562
  } catch (Exception e) {
1285
1563
  Log.e(TAG, "Error setting zoom: " + e.getMessage());
1286
1564
  }
@@ -1298,11 +1576,35 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1298
1576
  throw new Exception("Camera not initialized");
1299
1577
  }
1300
1578
 
1301
- Log.d(TAG, "setFocus: Setting focus at (" + x + ", " + y + ")");
1579
+ if (previewView == null) {
1580
+ throw new Exception("Preview view not initialized");
1581
+ }
1582
+
1583
+ // Validate that coordinates are within bounds (0-1 range)
1584
+ if (x < 0f || x > 1f || y < 0f || y > 1f) {
1585
+ Log.w(TAG, "setFocus: Coordinates out of bounds - x: " + x + ", y: " + y);
1586
+ throw new Exception("Focus coordinates must be between 0 and 1");
1587
+ }
1588
+
1589
+ // Cancel any ongoing focus operation
1590
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
1591
+ Log.d(TAG, "setFocus: Cancelling previous focus operation");
1592
+ currentFocusFuture.cancel(true);
1593
+ }
1302
1594
 
1303
- // Convert normalized coordinates (0-1) to view coordinates
1304
1595
  int viewWidth = previewView.getWidth();
1305
1596
  int viewHeight = previewView.getHeight();
1597
+
1598
+ if (viewWidth <= 0 || viewHeight <= 0) {
1599
+ throw new Exception(
1600
+ "Preview view has invalid dimensions: " + viewWidth + "x" + viewHeight
1601
+ );
1602
+ }
1603
+
1604
+ // Only show focus indicator after validation passes
1605
+ float indicatorX = x * viewWidth;
1606
+ float indicatorY = y * viewHeight;
1607
+ showFocusIndicator(indicatorX, indicatorY);
1306
1608
 
1307
1609
  // Create MeteringPoint using the preview view
1308
1610
  MeteringPointFactory factory = previewView.getMeteringPointFactory();
@@ -1317,27 +1619,252 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1317
1619
  .build();
1318
1620
 
1319
1621
  try {
1320
- ListenableFuture<FocusMeteringResult> focusFuture = camera
1622
+ currentFocusFuture = camera
1321
1623
  .getCameraControl()
1322
1624
  .startFocusAndMetering(action);
1323
1625
 
1324
- focusFuture.addListener(
1626
+ currentFocusFuture.addListener(
1325
1627
  () -> {
1326
1628
  try {
1327
- FocusMeteringResult result = focusFuture.get();
1328
- Log.d(TAG, "Focus result: " + result.isFocusSuccessful());
1629
+ FocusMeteringResult result = currentFocusFuture.get();
1329
1630
  } catch (Exception e) {
1330
- Log.e(TAG, "Error during focus: " + e.getMessage());
1631
+ // Handle cancellation gracefully - this is expected when rapid taps occur
1632
+ if (
1633
+ e.getMessage() != null &&
1634
+ (e
1635
+ .getMessage()
1636
+ .contains("Cancelled by another startFocusAndMetering") ||
1637
+ e.getMessage().contains("OperationCanceledException") ||
1638
+ e
1639
+ .getClass()
1640
+ .getSimpleName()
1641
+ .contains("OperationCanceledException"))
1642
+ ) {
1643
+ Log.d(
1644
+ TAG,
1645
+ "Focus operation was cancelled by a newer focus request"
1646
+ );
1647
+ } else {
1648
+ Log.e(TAG, "Error during focus: " + e.getMessage());
1649
+ }
1650
+ } finally {
1651
+ // Clear the reference if this is still the current operation
1652
+ if (currentFocusFuture != null && currentFocusFuture.isDone()) {
1653
+ currentFocusFuture = null;
1654
+ }
1331
1655
  }
1332
1656
  },
1333
1657
  ContextCompat.getMainExecutor(context)
1334
1658
  );
1335
1659
  } catch (Exception e) {
1660
+ currentFocusFuture = null;
1336
1661
  Log.e(TAG, "Failed to set focus: " + e.getMessage());
1337
1662
  throw e;
1338
1663
  }
1339
1664
  }
1340
1665
 
1666
+ private void showFocusIndicator(float x, float y) {
1667
+ if (previewContainer == null) {
1668
+ Log.w(TAG, "showFocusIndicator: previewContainer is null");
1669
+ return;
1670
+ }
1671
+
1672
+ // Check if container has been laid out
1673
+ if (previewContainer.getWidth() == 0 || previewContainer.getHeight() == 0) {
1674
+ Log.w(
1675
+ TAG,
1676
+ "showFocusIndicator: previewContainer not laid out yet, posting to run after layout"
1677
+ );
1678
+ previewContainer.post(() -> showFocusIndicator(x, y));
1679
+ return;
1680
+ }
1681
+
1682
+ // Remove any existing focus indicator
1683
+ if (focusIndicatorView != null) {
1684
+ previewContainer.removeView(focusIndicatorView);
1685
+ focusIndicatorView = null;
1686
+ }
1687
+
1688
+ // Create an elegant focus indicator
1689
+ View container = new View(context);
1690
+ int size = (int) (60 * context.getResources().getDisplayMetrics().density); // 60dp size
1691
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size, size);
1692
+
1693
+ // Center the indicator on the touch point with bounds checking
1694
+ int containerWidth = previewContainer.getWidth();
1695
+ int containerHeight = previewContainer.getHeight();
1696
+
1697
+ params.leftMargin = Math.max(
1698
+ 0,
1699
+ Math.min((int) (x - size / 2), containerWidth - size)
1700
+ );
1701
+ params.topMargin = Math.max(
1702
+ 0,
1703
+ Math.min((int) (y - size / 2), containerHeight - size)
1704
+ );
1705
+
1706
+ // Create an elegant focus ring - white stroke with transparent center
1707
+ GradientDrawable drawable = new GradientDrawable();
1708
+ drawable.setShape(GradientDrawable.OVAL);
1709
+ drawable.setStroke(
1710
+ (int) (2 * context.getResources().getDisplayMetrics().density),
1711
+ Color.WHITE
1712
+ ); // 2dp white stroke
1713
+ drawable.setColor(Color.TRANSPARENT); // Transparent center
1714
+ container.setBackground(drawable);
1715
+
1716
+ focusIndicatorView = container;
1717
+
1718
+ // Set initial state for smooth animation
1719
+ focusIndicatorView.setAlpha(1f); // Start visible
1720
+ focusIndicatorView.setScaleX(1.8f); // Start larger for scale-in effect
1721
+ focusIndicatorView.setScaleY(1.8f);
1722
+ focusIndicatorView.setVisibility(View.VISIBLE);
1723
+
1724
+ // Ensure container doesn't intercept touch events
1725
+ container.setClickable(false);
1726
+ container.setFocusable(false);
1727
+
1728
+ // Ensure the focus indicator has a high elevation for visibility
1729
+ if (
1730
+ android.os.Build.VERSION.SDK_INT >=
1731
+ android.os.Build.VERSION_CODES.LOLLIPOP
1732
+ ) {
1733
+ focusIndicatorView.setElevation(10f);
1734
+ }
1735
+
1736
+ // Add to container first
1737
+ previewContainer.addView(focusIndicatorView, params);
1738
+
1739
+ // Fix z-ordering: ensure focus indicator is always on top
1740
+ focusIndicatorView.bringToFront();
1741
+
1742
+ // Force a layout pass to ensure the view is properly positioned
1743
+ previewContainer.requestLayout();
1744
+
1745
+ // Smooth scale down animation with easing (no fade needed since we start visible)
1746
+ ScaleAnimation scaleAnimation = new ScaleAnimation(
1747
+ 1.8f,
1748
+ 1.0f,
1749
+ 1.8f,
1750
+ 1.0f,
1751
+ Animation.RELATIVE_TO_SELF,
1752
+ 0.5f,
1753
+ Animation.RELATIVE_TO_SELF,
1754
+ 0.5f
1755
+ );
1756
+ scaleAnimation.setDuration(300);
1757
+ scaleAnimation.setInterpolator(
1758
+ new android.view.animation.OvershootInterpolator(1.2f)
1759
+ );
1760
+
1761
+ // Start the animation
1762
+ focusIndicatorView.startAnimation(scaleAnimation);
1763
+
1764
+ // Schedule fade out and removal with smoother timing
1765
+ focusIndicatorView.postDelayed(
1766
+ new Runnable() {
1767
+ @Override
1768
+ public void run() {
1769
+ if (focusIndicatorView != null) {
1770
+ // Smooth fade to semi-transparent
1771
+ AlphaAnimation fadeToTransparent = new AlphaAnimation(1f, 0.4f);
1772
+ fadeToTransparent.setDuration(400);
1773
+ fadeToTransparent.setInterpolator(
1774
+ new android.view.animation.AccelerateInterpolator()
1775
+ );
1776
+
1777
+ fadeToTransparent.setAnimationListener(
1778
+ new Animation.AnimationListener() {
1779
+ @Override
1780
+ public void onAnimationStart(Animation animation) {
1781
+ Log.d(TAG, "showFocusIndicator: Fade to transparent started");
1782
+ }
1783
+
1784
+ @Override
1785
+ public void onAnimationEnd(Animation animation) {
1786
+ Log.d(
1787
+ TAG,
1788
+ "showFocusIndicator: Fade to transparent ended, starting final fade out"
1789
+ );
1790
+ // Final smooth fade out and scale down
1791
+ if (focusIndicatorView != null) {
1792
+ AnimationSet finalAnimation = new AnimationSet(false);
1793
+
1794
+ AlphaAnimation finalFadeOut = new AlphaAnimation(0.4f, 0f);
1795
+ finalFadeOut.setDuration(500);
1796
+ finalFadeOut.setStartOffset(300);
1797
+ finalFadeOut.setInterpolator(
1798
+ new android.view.animation.AccelerateInterpolator()
1799
+ );
1800
+
1801
+ ScaleAnimation finalScaleDown = new ScaleAnimation(
1802
+ 1.0f,
1803
+ 0.9f,
1804
+ 1.0f,
1805
+ 0.9f,
1806
+ Animation.RELATIVE_TO_SELF,
1807
+ 0.5f,
1808
+ Animation.RELATIVE_TO_SELF,
1809
+ 0.5f
1810
+ );
1811
+ finalScaleDown.setDuration(500);
1812
+ finalScaleDown.setStartOffset(300);
1813
+ finalScaleDown.setInterpolator(
1814
+ new android.view.animation.AccelerateInterpolator()
1815
+ );
1816
+
1817
+ finalAnimation.addAnimation(finalFadeOut);
1818
+ finalAnimation.addAnimation(finalScaleDown);
1819
+
1820
+ finalAnimation.setAnimationListener(
1821
+ new Animation.AnimationListener() {
1822
+ @Override
1823
+ public void onAnimationStart(Animation animation) {
1824
+ Log.d(
1825
+ TAG,
1826
+ "showFocusIndicator: Final animation started"
1827
+ );
1828
+ }
1829
+
1830
+ @Override
1831
+ public void onAnimationEnd(Animation animation) {
1832
+ Log.d(
1833
+ TAG,
1834
+ "showFocusIndicator: Final animation ended, removing indicator"
1835
+ );
1836
+ // Remove the focus indicator
1837
+ if (
1838
+ focusIndicatorView != null &&
1839
+ previewContainer != null
1840
+ ) {
1841
+ previewContainer.removeView(focusIndicatorView);
1842
+ focusIndicatorView = null;
1843
+ }
1844
+ }
1845
+
1846
+ @Override
1847
+ public void onAnimationRepeat(Animation animation) {}
1848
+ }
1849
+ );
1850
+
1851
+ focusIndicatorView.startAnimation(finalAnimation);
1852
+ }
1853
+ }
1854
+
1855
+ @Override
1856
+ public void onAnimationRepeat(Animation animation) {}
1857
+ }
1858
+ );
1859
+
1860
+ focusIndicatorView.startAnimation(fadeToTransparent);
1861
+ }
1862
+ }
1863
+ },
1864
+ 800
1865
+ ); // Optimal timing for smooth focus feedback
1866
+ }
1867
+
1341
1868
  public static List<Size> getSupportedPictureSizes(String facing) {
1342
1869
  List<Size> sizes = new ArrayList<>();
1343
1870
  try {
@@ -1800,54 +2327,138 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1800
2327
 
1801
2328
  public int getPreviewX() {
1802
2329
  if (previewContainer == null) return 0;
2330
+
2331
+ // Get the container position
1803
2332
  ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
1804
2333
  if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
1805
- // Return position relative to WebView content (subtract insets)
1806
- int margin = ((ViewGroup.MarginLayoutParams) layoutParams).leftMargin;
1807
- int leftInset = getWebViewLeftInset();
1808
- int result = margin - leftInset;
2334
+ int containerX = ((ViewGroup.MarginLayoutParams) layoutParams).leftMargin;
2335
+
2336
+ // Get the actual camera bounds within the container
2337
+ Rect cameraBounds = getActualCameraBounds();
2338
+ int actualX = containerX + cameraBounds.left;
2339
+
1809
2340
  Log.d(
1810
2341
  TAG,
1811
- "getPreviewX: leftMargin=" +
1812
- margin +
1813
- ", leftInset=" +
1814
- leftInset +
1815
- ", result=" +
1816
- result
2342
+ "getPreviewX: containerX=" +
2343
+ containerX +
2344
+ ", cameraBounds.left=" +
2345
+ cameraBounds.left +
2346
+ ", actualX=" +
2347
+ actualX
1817
2348
  );
1818
- return result;
2349
+
2350
+ return actualX;
1819
2351
  }
1820
2352
  return previewContainer.getLeft();
1821
2353
  }
1822
2354
 
1823
2355
  public int getPreviewY() {
1824
2356
  if (previewContainer == null) return 0;
2357
+
2358
+ // Get the container position
1825
2359
  ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
1826
2360
  if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
1827
- // Return position relative to WebView content (subtract insets)
1828
- int margin = ((ViewGroup.MarginLayoutParams) layoutParams).topMargin;
1829
- int topInset = getWebViewTopInset();
1830
- int result = margin - topInset;
2361
+ int containerY = ((ViewGroup.MarginLayoutParams) layoutParams).topMargin;
2362
+
2363
+ // Get the actual camera bounds within the container
2364
+ Rect cameraBounds = getActualCameraBounds();
2365
+ int actualY = containerY + cameraBounds.top;
2366
+
1831
2367
  Log.d(
1832
2368
  TAG,
1833
- "getPreviewY: topMargin=" +
1834
- margin +
1835
- ", topInset=" +
1836
- topInset +
1837
- ", result=" +
1838
- result
2369
+ "getPreviewY: containerY=" +
2370
+ containerY +
2371
+ ", cameraBounds.top=" +
2372
+ cameraBounds.top +
2373
+ ", actualY=" +
2374
+ actualY
1839
2375
  );
1840
- return result;
2376
+
2377
+ return actualY;
1841
2378
  }
1842
2379
  return previewContainer.getTop();
1843
2380
  }
1844
2381
 
2382
+ // Get the actual camera content bounds within the PreviewView
2383
+ private Rect getActualCameraBounds() {
2384
+ if (previewView == null || previewContainer == null) {
2385
+ return new Rect(0, 0, 0, 0);
2386
+ }
2387
+
2388
+ // Get the container bounds
2389
+ int containerWidth = previewContainer.getWidth();
2390
+ int containerHeight = previewContainer.getHeight();
2391
+
2392
+ // Get the preview transformation info to understand how the camera is scaled/positioned
2393
+ // For FIT_CENTER, the camera content is scaled to fit within the container
2394
+ // This might create letterboxing (black bars) on top/bottom or left/right
2395
+
2396
+ // Get the actual preview resolution
2397
+ if (currentPreviewResolution == null) {
2398
+ // If we don't have the resolution yet, assume the container is filled
2399
+ return new Rect(0, 0, containerWidth, containerHeight);
2400
+ }
2401
+
2402
+ // The preview is rotated 90 degrees for portrait mode
2403
+ // So we swap the dimensions
2404
+ int cameraWidth = currentPreviewResolution.getHeight();
2405
+ int cameraHeight = currentPreviewResolution.getWidth();
2406
+
2407
+ // Calculate the scaling factor to fit the camera in the container
2408
+ float widthScale = (float) containerWidth / cameraWidth;
2409
+ float heightScale = (float) containerHeight / cameraHeight;
2410
+ float scale = Math.min(widthScale, heightScale); // FIT_CENTER uses min scale
2411
+
2412
+ // Calculate the actual size of the camera content after scaling
2413
+ int scaledWidth = Math.round(cameraWidth * scale);
2414
+ int scaledHeight = Math.round(cameraHeight * scale);
2415
+
2416
+ // Calculate the offset to center the content
2417
+ int offsetX = (containerWidth - scaledWidth) / 2;
2418
+ int offsetY = (containerHeight - scaledHeight) / 2;
2419
+
2420
+ Log.d(
2421
+ TAG,
2422
+ "getActualCameraBounds: container=" +
2423
+ containerWidth +
2424
+ "x" +
2425
+ containerHeight +
2426
+ ", camera=" +
2427
+ cameraWidth +
2428
+ "x" +
2429
+ cameraHeight +
2430
+ ", scale=" +
2431
+ scale +
2432
+ ", scaled=" +
2433
+ scaledWidth +
2434
+ "x" +
2435
+ scaledHeight +
2436
+ ", offset=(" +
2437
+ offsetX +
2438
+ "," +
2439
+ offsetY +
2440
+ ")"
2441
+ );
2442
+
2443
+ // Return the bounds relative to the container
2444
+ return new Rect(
2445
+ offsetX,
2446
+ offsetY,
2447
+ offsetX + scaledWidth,
2448
+ offsetY + scaledHeight
2449
+ );
2450
+ }
2451
+
1845
2452
  public int getPreviewWidth() {
1846
- return previewContainer != null ? previewContainer.getWidth() : 0;
2453
+ if (previewContainer == null) return 0;
2454
+ Rect bounds = getActualCameraBounds();
2455
+ return bounds.width();
1847
2456
  }
1848
2457
 
1849
2458
  public int getPreviewHeight() {
1850
- return previewContainer != null ? previewContainer.getHeight() : 0;
2459
+ if (previewContainer == null) return 0;
2460
+ Rect bounds = getActualCameraBounds();
2461
+ return bounds.height();
1851
2462
  }
1852
2463
 
1853
2464
  public void setPreviewSize(int x, int y, int width, int height) {
@@ -2167,22 +2778,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2167
2778
  }
2168
2779
  }
2169
2780
 
2170
- private void updatePreviewLayout() {
2171
- if (previewContainer == null || sessionConfig == null) return;
2172
-
2173
- String aspectRatio = sessionConfig.getAspectRatio();
2174
- if (aspectRatio == null) return;
2175
-
2176
- updatePreviewLayoutForAspectRatio(aspectRatio);
2177
- }
2178
-
2179
2781
  private int getWebViewTopInset() {
2180
2782
  try {
2181
2783
  if (webView != null) {
2182
- ViewGroup.LayoutParams layoutParams = webView.getLayoutParams();
2183
- if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2184
- return ((ViewGroup.MarginLayoutParams) layoutParams).topMargin;
2185
- }
2784
+ // Get the actual WebView position on screen
2785
+ int[] location = new int[2];
2786
+ webView.getLocationOnScreen(location);
2787
+ return location[1]; // Y position is the top inset
2186
2788
  }
2187
2789
  } catch (Exception e) {
2188
2790
  Log.w(TAG, "Failed to get WebView top inset", e);
@@ -2193,10 +2795,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2193
2795
  private int getWebViewLeftInset() {
2194
2796
  try {
2195
2797
  if (webView != null) {
2196
- ViewGroup.LayoutParams layoutParams = webView.getLayoutParams();
2197
- if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2198
- return ((ViewGroup.MarginLayoutParams) layoutParams).leftMargin;
2199
- }
2798
+ // Get the actual WebView position on screen for consistency
2799
+ int[] location = new int[2];
2800
+ webView.getLocationOnScreen(location);
2801
+ return location[0]; // X position is the left inset
2200
2802
  }
2201
2803
  } catch (Exception e) {
2202
2804
  Log.w(TAG, "Failed to get WebView left inset", e);
@@ -2212,32 +2814,112 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2212
2814
  return new int[] { 0, 0, 0, 0 }; // x, y, width, height
2213
2815
  }
2214
2816
 
2215
- ViewGroup.LayoutParams layoutParams = previewContainer.getLayoutParams();
2216
- int x = 0, y = 0, width = 0, height = 0;
2817
+ // Get actual camera preview bounds (accounts for letterboxing/pillarboxing)
2818
+ int actualX = getPreviewX();
2819
+ int actualY = getPreviewY();
2820
+ int actualWidth = getPreviewWidth();
2821
+ int actualHeight = getPreviewHeight();
2217
2822
 
2218
- if (layoutParams instanceof ViewGroup.MarginLayoutParams) {
2219
- ViewGroup.MarginLayoutParams params =
2220
- (ViewGroup.MarginLayoutParams) layoutParams;
2823
+ // Convert to logical pixels for JavaScript
2824
+ DisplayMetrics metrics = context.getResources().getDisplayMetrics();
2825
+ float pixelRatio = metrics.density;
2221
2826
 
2222
- // Remove insets to get original coordinates in DP
2223
- DisplayMetrics metrics = context.getResources().getDisplayMetrics();
2224
- float pixelRatio = metrics.density;
2827
+ // Remove WebView insets from coordinates
2828
+ int webViewTopInset = getWebViewTopInset();
2829
+ int webViewLeftInset = getWebViewLeftInset();
2225
2830
 
2226
- int webViewTopInset = getWebViewTopInset();
2227
- int webViewLeftInset = getWebViewLeftInset();
2831
+ int x = Math.max(
2832
+ 0,
2833
+ (int) ((actualX - webViewLeftInset) / pixelRatio)
2834
+ );
2835
+ int y = Math.max(
2836
+ 0,
2837
+ (int) ((actualY - webViewTopInset) / pixelRatio)
2838
+ );
2839
+ int width = (int) (actualWidth / pixelRatio);
2840
+ int height = (int) (actualHeight / pixelRatio);
2228
2841
 
2229
- x = Math.max(
2230
- 0,
2231
- (int) ((params.leftMargin - webViewLeftInset) / pixelRatio)
2232
- );
2233
- y = Math.max(
2234
- 0,
2235
- (int) ((params.topMargin - webViewTopInset) / pixelRatio)
2236
- );
2237
- width = (int) (params.width / pixelRatio);
2238
- height = (int) (params.height / pixelRatio);
2842
+ return new int[] { x, y, width, height };
2843
+ }
2844
+
2845
+ private void triggerAutoFocus() {
2846
+ if (camera == null) {
2847
+ return;
2239
2848
  }
2240
2849
 
2241
- return new int[] { x, y, width, height };
2850
+ Log.d(TAG, "triggerAutoFocus: Triggering autofocus at center");
2851
+
2852
+ // Cancel any ongoing focus operation
2853
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
2854
+ Log.d(TAG, "triggerAutoFocus: Cancelling previous focus operation");
2855
+ currentFocusFuture.cancel(true);
2856
+ }
2857
+
2858
+ // Focus on the center of the view
2859
+ int viewWidth = previewView.getWidth();
2860
+ int viewHeight = previewView.getHeight();
2861
+
2862
+ if (viewWidth == 0 || viewHeight == 0) {
2863
+ return;
2864
+ }
2865
+
2866
+ // Create MeteringPoint at the center of the preview
2867
+ MeteringPointFactory factory = previewView.getMeteringPointFactory();
2868
+ MeteringPoint point = factory.createPoint(viewWidth / 2f, viewHeight / 2f);
2869
+
2870
+ // Create focus and metering action
2871
+ FocusMeteringAction action = new FocusMeteringAction.Builder(
2872
+ point,
2873
+ FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE
2874
+ )
2875
+ .setAutoCancelDuration(3, TimeUnit.SECONDS) // Auto-cancel after 3 seconds
2876
+ .build();
2877
+
2878
+ try {
2879
+ currentFocusFuture = camera
2880
+ .getCameraControl()
2881
+ .startFocusAndMetering(action);
2882
+ currentFocusFuture.addListener(
2883
+ () -> {
2884
+ try {
2885
+ FocusMeteringResult result = currentFocusFuture.get();
2886
+ Log.d(
2887
+ TAG,
2888
+ "triggerAutoFocus: Focus completed successfully: " +
2889
+ result.isFocusSuccessful()
2890
+ );
2891
+ } catch (Exception e) {
2892
+ // Handle cancellation gracefully - this is expected when rapid operations occur
2893
+ if (
2894
+ e.getMessage() != null &&
2895
+ (e
2896
+ .getMessage()
2897
+ .contains("Cancelled by another startFocusAndMetering") ||
2898
+ e.getMessage().contains("OperationCanceledException") ||
2899
+ e
2900
+ .getClass()
2901
+ .getSimpleName()
2902
+ .contains("OperationCanceledException"))
2903
+ ) {
2904
+ Log.d(
2905
+ TAG,
2906
+ "triggerAutoFocus: Auto-focus was cancelled by a newer focus request"
2907
+ );
2908
+ } else {
2909
+ Log.e(TAG, "triggerAutoFocus: Error during focus", e);
2910
+ }
2911
+ } finally {
2912
+ // Clear the reference if this is still the current operation
2913
+ if (currentFocusFuture != null && currentFocusFuture.isDone()) {
2914
+ currentFocusFuture = null;
2915
+ }
2916
+ }
2917
+ },
2918
+ ContextCompat.getMainExecutor(context)
2919
+ );
2920
+ } catch (Exception e) {
2921
+ currentFocusFuture = null;
2922
+ Log.e(TAG, "triggerAutoFocus: Failed to trigger autofocus", e);
2923
+ }
2242
2924
  }
2243
2925
  }