@capgo/camera-preview 7.14.7 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
- package com.ahm.capacitor.camera.preview;
2
-
3
- import static androidx.core.content.ContextCompat.getSystemService;
1
+ package app.capgo.capacitor.camera.preview;
4
2
 
3
+ import android.Manifest;
5
4
  import android.content.Context;
5
+ import android.content.pm.PackageManager;
6
6
  import android.content.res.Configuration;
7
7
  import android.graphics.Bitmap;
8
8
  import android.graphics.BitmapFactory;
9
9
  import android.graphics.Color;
10
+ import android.graphics.Matrix;
10
11
  import android.graphics.Rect;
11
12
  import android.graphics.drawable.GradientDrawable;
12
13
  import android.hardware.camera2.CameraAccessException;
@@ -23,7 +24,6 @@ import android.util.Log;
23
24
  import android.util.Range;
24
25
  import android.util.Rational;
25
26
  import android.util.Size;
26
- import android.view.MotionEvent;
27
27
  import android.view.View;
28
28
  import android.view.ViewGroup;
29
29
  import android.view.WindowManager;
@@ -32,7 +32,6 @@ import android.webkit.WebView;
32
32
  import android.widget.FrameLayout;
33
33
  import androidx.annotation.NonNull;
34
34
  import androidx.annotation.OptIn;
35
- import androidx.annotation.RequiresApi;
36
35
  import androidx.camera.camera2.interop.Camera2CameraControl;
37
36
  import androidx.camera.camera2.interop.Camera2CameraInfo;
38
37
  import androidx.camera.camera2.interop.CaptureRequestOptions;
@@ -52,7 +51,6 @@ import androidx.camera.core.MeteringPointFactory;
52
51
  import androidx.camera.core.Preview;
53
52
  import androidx.camera.core.ResolutionInfo;
54
53
  import androidx.camera.core.TorchState;
55
- import androidx.camera.core.UseCase;
56
54
  import androidx.camera.core.ZoomState;
57
55
  import androidx.camera.core.resolutionselector.AspectRatioStrategy;
58
56
  import androidx.camera.core.resolutionselector.ResolutionSelector;
@@ -67,16 +65,18 @@ import androidx.camera.video.Recording;
67
65
  import androidx.camera.video.VideoCapture;
68
66
  import androidx.camera.video.VideoRecordEvent;
69
67
  import androidx.camera.view.PreviewView;
68
+ import androidx.core.app.ActivityCompat;
70
69
  import androidx.core.content.ContextCompat;
71
70
  import androidx.exifinterface.media.ExifInterface;
72
71
  import androidx.lifecycle.Lifecycle;
73
72
  import androidx.lifecycle.LifecycleObserver;
74
73
  import androidx.lifecycle.LifecycleOwner;
75
74
  import androidx.lifecycle.LifecycleRegistry;
76
- import com.ahm.capacitor.camera.preview.model.CameraSessionConfiguration;
77
- import com.ahm.capacitor.camera.preview.model.LensInfo;
78
- import com.ahm.capacitor.camera.preview.model.ZoomFactors;
75
+ import app.capgo.capacitor.camera.preview.model.CameraSessionConfiguration;
76
+ import app.capgo.capacitor.camera.preview.model.LensInfo;
77
+ import app.capgo.capacitor.camera.preview.model.ZoomFactors;
79
78
  import com.google.common.util.concurrent.ListenableFuture;
79
+ import java.io.ByteArrayInputStream;
80
80
  import java.io.ByteArrayOutputStream;
81
81
  import java.io.File;
82
82
  import java.io.FileOutputStream;
@@ -85,7 +85,6 @@ import java.nio.ByteBuffer;
85
85
  import java.text.SimpleDateFormat;
86
86
  import java.util.ArrayList;
87
87
  import java.util.Arrays;
88
- import java.util.Arrays;
89
88
  import java.util.Collections;
90
89
  import java.util.List;
91
90
  import java.util.Locale;
@@ -99,6 +98,7 @@ import org.json.JSONObject;
99
98
  public class CameraXView implements LifecycleOwner, LifecycleObserver {
100
99
 
101
100
  private static final String TAG = "CameraPreview CameraXView";
101
+ private static final String FOCUS_INDICATOR_TAG = "cpcp_focus_indicator";
102
102
 
103
103
  public interface CameraXViewListener {
104
104
  void onPictureTaken(String base64, JSONObject exif);
@@ -124,12 +124,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
124
124
  private File currentVideoFile;
125
125
  private VideoRecordingCallback currentVideoCallback;
126
126
  private PreviewView previewView;
127
- private Preview previewUseCase;
128
127
  private GridOverlayView gridOverlayView;
129
128
  private FrameLayout previewContainer;
130
129
  private View focusIndicatorView;
131
130
  private long focusIndicatorAnimationId = 0; // Incrementing token to invalidate previous animations
132
- private boolean disableFocusIndicator = false; // Default to false for backward compatibility
133
131
  private CameraSelector currentCameraSelector;
134
132
  private String currentDeviceId;
135
133
  private int currentFlashMode = ImageCapture.FLASH_MODE_OFF;
@@ -144,21 +142,55 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
144
142
  private Size currentPreviewResolution = null;
145
143
  private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
146
144
  private String currentExposureMode = "CONTINUOUS"; // Default behavior
147
- private boolean isVideoCaptureInitializing = false;
148
145
  // Capture/stop coordination
149
146
  private final Object captureLock = new Object();
150
147
  private volatile boolean isCapturingPhoto = false;
151
148
  private volatile boolean stopRequested = false;
152
149
  private volatile boolean previewDetachedOnDeferredStop = false;
153
150
 
154
- public boolean isCapturing() {
155
- return isCapturingPhoto;
151
+ // Operation coordination (acts like a semaphore to prevent stop during active ops)
152
+ private final Object operationLock = new Object();
153
+ private int activeOperations = 0;
154
+ private boolean stopPending = false;
155
+
156
+ private boolean IsOperationRunning(String name) {
157
+ synchronized (operationLock) {
158
+ if (stopPending) {
159
+ Log.d(TAG, "beginOperation: blocked '" + name + "' due to stopPending");
160
+ return true;
161
+ }
162
+ activeOperations++;
163
+ Log.v(
164
+ TAG,
165
+ "beginOperation: '" + name + "' (active=" + activeOperations + ")"
166
+ );
167
+ return false;
168
+ }
156
169
  }
157
170
 
158
- public boolean isStopDeferred() {
159
- synchronized (captureLock) {
160
- return isCapturingPhoto && stopRequested;
171
+ private void endOperation(String name) {
172
+ boolean shouldStop = false;
173
+ synchronized (operationLock) {
174
+ if (activeOperations > 0) activeOperations--;
175
+ Log.v(
176
+ TAG,
177
+ "endOperation: '" + name + "' (active=" + activeOperations + ")"
178
+ );
179
+ if (activeOperations == 0 && stopPending) {
180
+ shouldStop = true;
181
+ }
161
182
  }
183
+ if (shouldStop) {
184
+ Log.d(
185
+ TAG,
186
+ "endOperation: all operations complete; performing deferred stop"
187
+ );
188
+ performImmediateStop();
189
+ }
190
+ }
191
+
192
+ public boolean isCapturing() {
193
+ return isCapturingPhoto;
162
194
  }
163
195
 
164
196
  public boolean isBusy() {
@@ -269,9 +301,125 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
269
301
  }
270
302
  }
271
303
 
304
+ private void saveImageToGallery(
305
+ byte[] data,
306
+ ExifInterface sourceExif,
307
+ Integer finalWidth,
308
+ Integer finalHeight
309
+ ) {
310
+ try {
311
+ // First, write the bytes to a file
312
+ String extension = ".jpg";
313
+ String mimeType = "image/jpeg";
314
+ if (data.length >= 8) {
315
+ if (
316
+ data[0] == (byte) 0x89 &&
317
+ data[1] == 0x50 &&
318
+ data[2] == 0x4E &&
319
+ data[3] == 0x47
320
+ ) {
321
+ extension = ".png";
322
+ mimeType = "image/png";
323
+ } else if (
324
+ data[0] == (byte) 0xFF &&
325
+ data[1] == (byte) 0xD8 &&
326
+ data[2] == (byte) 0xFF
327
+ ) {
328
+ extension = ".jpg";
329
+ mimeType = "image/jpeg";
330
+ } else if (
331
+ data[0] == 0x52 &&
332
+ data[1] == 0x49 &&
333
+ data[2] == 0x46 &&
334
+ data[3] == 0x46 &&
335
+ data.length >= 12 &&
336
+ data[8] == 0x57 &&
337
+ data[9] == 0x45 &&
338
+ data[10] == 0x42 &&
339
+ data[11] == 0x50
340
+ ) {
341
+ extension = ".webp";
342
+ mimeType = "image/webp";
343
+ }
344
+ }
345
+
346
+ File photo = new File(
347
+ Environment.getExternalStoragePublicDirectory(
348
+ Environment.DIRECTORY_PICTURES
349
+ ),
350
+ "IMG_" +
351
+ new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(
352
+ new java.util.Date()
353
+ ) +
354
+ extension
355
+ );
356
+
357
+ FileOutputStream fos = new FileOutputStream(photo);
358
+ fos.write(data);
359
+ fos.flush();
360
+ fos.close();
361
+
362
+ // If JPEG and we have source EXIF, copy EXIF tags into the saved file
363
+ if ("image/jpeg".equals(mimeType) && sourceExif != null) {
364
+ try {
365
+ ExifInterface newExif = new ExifInterface(photo.getAbsolutePath());
366
+ for (String[] tag : EXIF_TAGS) {
367
+ String value = sourceExif.getAttribute(tag[0]);
368
+ if (value != null) newExif.setAttribute(tag[0], value);
369
+ }
370
+ // Normalize orientation for recompressed pixels
371
+ newExif.setAttribute(
372
+ ExifInterface.TAG_ORIENTATION,
373
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
374
+ );
375
+ if (
376
+ finalWidth != null &&
377
+ finalHeight != null &&
378
+ finalWidth > 0 &&
379
+ finalHeight > 0
380
+ ) {
381
+ newExif.setAttribute(
382
+ ExifInterface.TAG_PIXEL_X_DIMENSION,
383
+ String.valueOf(finalWidth)
384
+ );
385
+ newExif.setAttribute(
386
+ ExifInterface.TAG_PIXEL_Y_DIMENSION,
387
+ String.valueOf(finalHeight)
388
+ );
389
+ newExif.setAttribute(
390
+ ExifInterface.TAG_IMAGE_WIDTH,
391
+ String.valueOf(finalWidth)
392
+ );
393
+ newExif.setAttribute(
394
+ ExifInterface.TAG_IMAGE_LENGTH,
395
+ String.valueOf(finalHeight)
396
+ );
397
+ }
398
+ newExif.saveAttributes();
399
+ } catch (Exception ex) {
400
+ Log.w(TAG, "Failed to write EXIF to saved gallery image", ex);
401
+ }
402
+ }
403
+
404
+ // Notify the gallery of the new image
405
+ MediaScannerConnection.scanFile(
406
+ this.context,
407
+ new String[] { photo.getAbsolutePath() },
408
+ new String[] { mimeType },
409
+ null
410
+ );
411
+ } catch (IOException e) {
412
+ Log.e(TAG, "Error saving image to gallery (with exif)", e);
413
+ }
414
+ }
415
+
272
416
  public void startSession(CameraSessionConfiguration config) {
273
417
  this.sessionConfig = config;
274
418
  cameraExecutor = Executors.newSingleThreadExecutor();
419
+ synchronized (operationLock) {
420
+ activeOperations = 0;
421
+ stopPending = false;
422
+ }
275
423
  mainExecutor.execute(() -> {
276
424
  lifecycleRegistry.setCurrentState(Lifecycle.State.STARTED);
277
425
  setupCamera();
@@ -279,26 +427,38 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
279
427
  }
280
428
 
281
429
  public void stopSession() {
282
- // If a capture is in progress, defer heavy teardown until it completes.
283
- synchronized (captureLock) {
284
- if (isCapturingPhoto) {
285
- stopRequested = true;
286
- // Hide/detach the preview immediately so UI can close, but keep camera running
287
- if (!previewDetachedOnDeferredStop) {
288
- mainExecutor.execute(() -> {
289
- try {
290
- if (previewContainer != null) {
291
- ViewGroup parent = (ViewGroup) previewContainer.getParent();
292
- if (parent != null) {
293
- parent.removeView(previewContainer);
294
- }
430
+ // Mark stop pending; reject new operations and wait for active ones to finish
431
+ synchronized (operationLock) {
432
+ stopPending = true;
433
+ }
434
+ stopRequested = true;
435
+
436
+ boolean hasOps;
437
+ synchronized (operationLock) {
438
+ hasOps = activeOperations > 0;
439
+ }
440
+ if (hasOps) {
441
+ // Detach preview so UI can close
442
+ if (!previewDetachedOnDeferredStop) {
443
+ mainExecutor.execute(() -> {
444
+ try {
445
+ if (previewContainer != null) {
446
+ ViewGroup parent = (ViewGroup) previewContainer.getParent();
447
+ if (parent != null) {
448
+ parent.removeView(previewContainer);
295
449
  }
296
- previewDetachedOnDeferredStop = true;
297
- } catch (Exception ignored) {}
298
- });
299
- }
300
- return;
450
+ }
451
+ previewDetachedOnDeferredStop = true;
452
+ } catch (Exception ignored) {}
453
+ });
301
454
  }
455
+ // Cancel focus to hasten completion
456
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
457
+ try {
458
+ currentFocusFuture.cancel(true);
459
+ } catch (Exception ignored) {}
460
+ }
461
+ return;
302
462
  }
303
463
 
304
464
  performImmediateStop();
@@ -328,6 +488,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
328
488
  } finally {
329
489
  stopRequested = false;
330
490
  previewDetachedOnDeferredStop = false;
491
+ synchronized (operationLock) {
492
+ activeOperations = 0;
493
+ stopPending = false;
494
+ }
331
495
  if (listener != null) {
332
496
  try {
333
497
  listener.onCameraStopped();
@@ -381,6 +545,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
381
545
 
382
546
  // Create and setup the preview view
383
547
  previewView = new PreviewView(context);
548
+ // Use TextureView-backed implementation for broader device compatibility when overlaying with WebView
549
+ // This avoids SurfaceView z-order issues seen on some MIUI/EMUI devices.
550
+ previewView.setImplementationMode(
551
+ PreviewView.ImplementationMode.COMPATIBLE
552
+ );
384
553
  // Match iOS behavior: FIT when no aspect ratio, FILL when aspect ratio is set
385
554
  String initialAspectRatio = sessionConfig != null
386
555
  ? sessionConfig.getAspectRatio()
@@ -394,58 +563,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
394
563
  previewView.setClickable(true);
395
564
  previewView.setFocusable(true);
396
565
 
397
- // Add touch listener to both container and preview view for maximum compatibility
398
- View.OnTouchListener touchListener = new View.OnTouchListener() {
399
- @Override
400
- public boolean onTouch(View v, MotionEvent event) {
401
- Log.d(
402
- TAG,
403
- "onTouch: " +
404
- v.getClass().getSimpleName() +
405
- " received touch event: " +
406
- event.getAction() +
407
- " at (" +
408
- event.getX() +
409
- ", " +
410
- event.getY() +
411
- ")"
412
- );
413
-
414
- if (event.getAction() == MotionEvent.ACTION_DOWN) {
415
- float x = event.getX() / v.getWidth();
416
- float y = event.getY() / v.getHeight();
417
-
418
- Log.d(
419
- TAG,
420
- "onTouch: Touch detected at raw coords (" +
421
- event.getX() +
422
- ", " +
423
- event.getY() +
424
- "), view size: " +
425
- v.getWidth() +
426
- "x" +
427
- v.getHeight() +
428
- ", normalized: (" +
429
- x +
430
- ", " +
431
- y +
432
- ")"
433
- );
434
-
435
- try {
436
- // Trigger focus with indicator
437
- setFocus(x, y);
438
- } catch (Exception e) {
439
- Log.e(TAG, "Error during tap-to-focus: " + e.getMessage(), e);
440
- }
441
- return true;
442
- }
443
- return false;
444
- }
445
- };
446
-
447
- previewContainer.setOnTouchListener(touchListener);
448
- previewView.setOnTouchListener(touchListener);
566
+ // Intentionally no native gesture handling (tap-to-focus, pinch-to-zoom)
567
+ // Focus and zoom are controlled exclusively via JS API calls for parity with iOS.
449
568
 
450
569
  previewContainer.addView(
451
570
  previewView,
@@ -578,6 +697,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
578
697
  int webViewHeight = webView != null ? webView.getHeight() : 0;
579
698
 
580
699
  // Get parent dimensions
700
+ assert webView != null;
581
701
  ViewGroup parent = (ViewGroup) webView.getParent();
582
702
  int parentWidth = parent != null ? parent.getWidth() : 0;
583
703
  int parentHeight = parent != null ? parent.getHeight() : 0;
@@ -663,20 +783,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
663
783
  );
664
784
 
665
785
  // For centered mode with aspect ratio, calculate maximum size that fits
666
- int availableWidth = screenWidthPx;
667
- int availableHeight = screenHeightPx;
668
786
 
669
787
  Log.d(
670
788
  TAG,
671
789
  "Available space for preview: " +
672
- availableWidth +
790
+ screenWidthPx +
673
791
  "x" +
674
- availableHeight
792
+ screenHeightPx
675
793
  );
676
794
 
677
795
  // Calculate maximum size that fits the aspect ratio in available space
678
- double maxWidthByHeight = availableHeight * ratio;
679
- double maxHeightByWidth = availableWidth / ratio;
796
+ double maxWidthByHeight = screenHeightPx * ratio;
797
+ double maxHeightByWidth = screenWidthPx / ratio;
680
798
 
681
799
  Log.d(
682
800
  TAG,
@@ -686,21 +804,21 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
686
804
  maxHeightByWidth
687
805
  );
688
806
 
689
- if (maxWidthByHeight <= availableWidth) {
807
+ if (maxWidthByHeight <= screenWidthPx) {
690
808
  // Height is the limiting factor
691
809
  width = (int) maxWidthByHeight;
692
- height = availableHeight;
810
+ height = screenHeightPx;
693
811
  Log.d(TAG, "Height-limited sizing: " + width + "x" + height);
694
812
  } else {
695
813
  // Width is the limiting factor
696
- width = availableWidth;
814
+ width = screenWidthPx;
697
815
  height = (int) maxHeightByWidth;
698
816
  Log.d(TAG, "Width-limited sizing: " + width + "x" + height);
699
817
  }
700
818
 
701
819
  // Center the preview
702
- x = (availableWidth - width) / 2;
703
- y = (availableHeight - height) / 2;
820
+ x = (screenWidthPx - width) / 2;
821
+ y = (screenHeightPx - height) / 2;
704
822
 
705
823
  Log.d(TAG, "Auto-centered position: x=" + x + ", y=" + y);
706
824
  } catch (NumberFormatException e) {
@@ -805,7 +923,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
805
923
  .setTargetRotation(rotation)
806
924
  .build();
807
925
  // Keep reference to preview use case for later re-binding (e.g., when enabling video)
808
- previewUseCase = preview;
809
926
  imageCapture = new ImageCapture.Builder()
810
927
  .setResolutionSelector(resolutionSelector)
811
928
  .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
@@ -1062,25 +1179,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1062
1179
  return builder.build();
1063
1180
  }
1064
1181
 
1065
- private static String getCameraId(
1066
- androidx.camera.core.CameraInfo cameraInfo
1067
- ) {
1068
- try {
1069
- // Generate a stable ID based on camera characteristics
1070
- boolean isBack = isBackCamera(cameraInfo);
1071
- float minZoom = Objects.requireNonNull(
1072
- cameraInfo.getZoomState().getValue()
1073
- ).getMinZoomRatio();
1074
- float maxZoom = cameraInfo.getZoomState().getValue().getMaxZoomRatio();
1075
-
1076
- // Create a unique ID based on camera properties
1077
- String position = isBack ? "back" : "front";
1078
- return position + "_" + minZoom + "_" + maxZoom;
1079
- } catch (Exception e) {
1080
- return "unknown_camera";
1081
- }
1082
- }
1083
-
1084
1182
  private static boolean isBackCamera(
1085
1183
  androidx.camera.core.CameraInfo cameraInfo
1086
1184
  ) {
@@ -1108,6 +1206,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1108
1206
  Integer height,
1109
1207
  Location location
1110
1208
  ) {
1209
+ // Prevent capture if a stop is pending
1210
+ if (IsOperationRunning("capturePhoto")) {
1211
+ Log.d(TAG, "capturePhoto: Ignored because stop is pending");
1212
+ return;
1213
+ }
1111
1214
  Log.d(
1112
1215
  TAG,
1113
1216
  "capturePhoto: Starting photo capture with quality: " +
@@ -1129,9 +1232,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1129
1232
  isCapturingPhoto = true;
1130
1233
  }
1131
1234
 
1132
- File tempFile = new File(context.getCacheDir(), "temp_image.jpg");
1235
+ ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
1236
+ ImageCapture.Metadata metadata = new ImageCapture.Metadata();
1237
+ if (location != null) {
1238
+ metadata.setLocation(location);
1239
+ }
1133
1240
  ImageCapture.OutputFileOptions outputFileOptions =
1134
- new ImageCapture.OutputFileOptions.Builder(tempFile).build();
1241
+ new ImageCapture.OutputFileOptions.Builder(imageStream)
1242
+ .setMetadata(metadata)
1243
+ .build();
1135
1244
 
1136
1245
  imageCapture.takePicture(
1137
1246
  outputFileOptions,
@@ -1152,6 +1261,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1152
1261
  performImmediateStop();
1153
1262
  }
1154
1263
  }
1264
+ endOperation("capturePhoto");
1155
1265
  }
1156
1266
 
1157
1267
  @Override
@@ -1159,20 +1269,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1159
1269
  @NonNull ImageCapture.OutputFileResults output
1160
1270
  ) {
1161
1271
  try {
1162
- // Read file using FileInputStream for compatibility
1163
- byte[] bytes = new byte[(int) tempFile.length()];
1164
- java.io.FileInputStream fis = new java.io.FileInputStream(tempFile);
1165
- fis.read(bytes);
1166
- fis.close();
1272
+ byte[] bytes = imageStream.toByteArray();
1273
+ int finalWidthOut = -1;
1274
+ int finalHeightOut = -1;
1167
1275
 
1168
1276
  ExifInterface exifInterface = new ExifInterface(
1169
- tempFile.getAbsolutePath()
1277
+ new ByteArrayInputStream(bytes)
1170
1278
  );
1171
-
1172
- if (location != null) {
1173
- exifInterface.setGpsInfo(location);
1174
- }
1175
-
1279
+ // Build EXIF JSON from captured bytes (location applied by metadata if provided)
1176
1280
  JSONObject exifData = getExifData(exifInterface);
1177
1281
 
1178
1282
  if (width != null || height != null) {
@@ -1181,6 +1285,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1181
1285
  0,
1182
1286
  bytes.length
1183
1287
  );
1288
+ bitmap = applyExifOrientation(bitmap, exifInterface);
1184
1289
  Bitmap resizedBitmap = resizeBitmapToMaxDimensions(
1185
1290
  bitmap,
1186
1291
  width,
@@ -1194,8 +1299,19 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1194
1299
  );
1195
1300
  bytes = stream.toByteArray();
1196
1301
 
1197
- // Write EXIF data back to resized image
1198
- bytes = writeExifToImageBytes(bytes, exifInterface);
1302
+ // Update EXIF JSON to reflect new dimensions; no in-place EXIF write to bytes
1303
+ try {
1304
+ exifData.put("PixelXDimension", resizedBitmap.getWidth());
1305
+ exifData.put("PixelYDimension", resizedBitmap.getHeight());
1306
+ exifData.put("ImageWidth", resizedBitmap.getWidth());
1307
+ exifData.put("ImageLength", resizedBitmap.getHeight());
1308
+ exifData.put(
1309
+ "Orientation",
1310
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
1311
+ );
1312
+ } catch (Exception ignore) {}
1313
+ finalWidthOut = resizedBitmap.getWidth();
1314
+ finalHeightOut = resizedBitmap.getHeight();
1199
1315
  } else {
1200
1316
  // No explicit size/ratio: crop to match current preview content
1201
1317
  Bitmap originalBitmap = BitmapFactory.decodeByteArray(
@@ -1203,6 +1319,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1203
1319
  0,
1204
1320
  bytes.length
1205
1321
  );
1322
+ originalBitmap = applyExifOrientation(
1323
+ originalBitmap,
1324
+ exifInterface
1325
+ );
1206
1326
  Bitmap previewCropped = cropBitmapToMatchPreview(originalBitmap);
1207
1327
  ByteArrayOutputStream stream = new ByteArrayOutputStream();
1208
1328
  previewCropped.compress(
@@ -1211,16 +1331,30 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1211
1331
  stream
1212
1332
  );
1213
1333
  bytes = stream.toByteArray();
1214
- // Preserve EXIF
1215
- bytes = writeExifToImageBytes(bytes, exifInterface);
1334
+ // Update EXIF JSON to reflect cropped dimensions; no in-place EXIF write to bytes
1335
+ try {
1336
+ exifData.put("PixelXDimension", previewCropped.getWidth());
1337
+ exifData.put("PixelYDimension", previewCropped.getHeight());
1338
+ exifData.put("ImageWidth", previewCropped.getWidth());
1339
+ exifData.put("ImageLength", previewCropped.getHeight());
1340
+ exifData.put(
1341
+ "Orientation",
1342
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
1343
+ );
1344
+ } catch (Exception ignore) {}
1345
+ finalWidthOut = previewCropped.getWidth();
1346
+ finalHeightOut = previewCropped.getHeight();
1216
1347
  }
1217
1348
 
1218
- // Save to gallery asynchronously if requested
1349
+ // Save to gallery asynchronously if requested, copy EXIF to file
1219
1350
  if (saveToGallery) {
1220
1351
  final byte[] finalBytes = bytes;
1221
- new Thread(() -> {
1222
- saveImageToGallery(finalBytes);
1223
- }).start();
1352
+ final ExifInterface exifForFile = exifInterface;
1353
+ final Integer fW = (finalWidthOut > 0) ? finalWidthOut : null;
1354
+ final Integer fH = (finalHeightOut > 0) ? finalHeightOut : null;
1355
+ new Thread(() ->
1356
+ saveImageToGallery(finalBytes, exifForFile, fW, fH)
1357
+ ).start();
1224
1358
  }
1225
1359
 
1226
1360
  String resultValue;
@@ -1241,6 +1375,42 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1241
1375
  outFos.write(bytes);
1242
1376
  outFos.close();
1243
1377
 
1378
+ // Write EXIF into the saved file
1379
+ try {
1380
+ ExifInterface newExif = new ExifInterface(
1381
+ outFile.getAbsolutePath()
1382
+ );
1383
+ for (String[] tag : EXIF_TAGS) {
1384
+ String value = exifInterface.getAttribute(tag[0]);
1385
+ if (value != null) newExif.setAttribute(tag[0], value);
1386
+ }
1387
+ newExif.setAttribute(
1388
+ ExifInterface.TAG_ORIENTATION,
1389
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
1390
+ );
1391
+ if (finalWidthOut > 0 && finalHeightOut > 0) {
1392
+ newExif.setAttribute(
1393
+ ExifInterface.TAG_PIXEL_X_DIMENSION,
1394
+ String.valueOf(finalWidthOut)
1395
+ );
1396
+ newExif.setAttribute(
1397
+ ExifInterface.TAG_PIXEL_Y_DIMENSION,
1398
+ String.valueOf(finalHeightOut)
1399
+ );
1400
+ newExif.setAttribute(
1401
+ ExifInterface.TAG_IMAGE_WIDTH,
1402
+ String.valueOf(finalWidthOut)
1403
+ );
1404
+ newExif.setAttribute(
1405
+ ExifInterface.TAG_IMAGE_LENGTH,
1406
+ String.valueOf(finalHeightOut)
1407
+ );
1408
+ }
1409
+ newExif.saveAttributes();
1410
+ } catch (Exception ex) {
1411
+ Log.w(TAG, "Failed to embed EXIF into output file", ex);
1412
+ }
1413
+
1244
1414
  // Return a file path; apps can convert via Capacitor.convertFileSrc on JS side
1245
1415
  resultValue = outFile.getAbsolutePath();
1246
1416
  } catch (IOException ioEx) {
@@ -1253,8 +1423,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1253
1423
  resultValue = Base64.encodeToString(bytes, Base64.NO_WRAP);
1254
1424
  }
1255
1425
 
1256
- tempFile.delete();
1257
-
1258
1426
  if (listener != null) {
1259
1427
  listener.onPictureTaken(resultValue, exifData);
1260
1428
  }
@@ -1273,14 +1441,56 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1273
1441
  performImmediateStop();
1274
1442
  }
1275
1443
  }
1444
+ endOperation("capturePhoto");
1276
1445
  }
1277
1446
  }
1278
1447
  }
1279
1448
  );
1280
1449
  }
1281
1450
 
1282
- private Bitmap resizeBitmap(Bitmap bitmap, int width, int height) {
1283
- return Bitmap.createScaledBitmap(bitmap, width, height, true);
1451
+ private int exifToDegrees(int exifOrientation) {
1452
+ switch (exifOrientation) {
1453
+ case ExifInterface.ORIENTATION_ROTATE_90:
1454
+ case ExifInterface.ORIENTATION_TRANSPOSE:
1455
+ return 90;
1456
+ case ExifInterface.ORIENTATION_ROTATE_180:
1457
+ return 180;
1458
+ case ExifInterface.ORIENTATION_ROTATE_270:
1459
+ case ExifInterface.ORIENTATION_TRANSVERSE:
1460
+ return 270;
1461
+ default:
1462
+ return 0;
1463
+ }
1464
+ }
1465
+
1466
+ private Bitmap applyExifOrientation(Bitmap bitmap, ExifInterface exif) {
1467
+ try {
1468
+ int orientation = exif.getAttributeInt(
1469
+ ExifInterface.TAG_ORIENTATION,
1470
+ ExifInterface.ORIENTATION_UNDEFINED
1471
+ );
1472
+ int rotation = exifToDegrees(orientation);
1473
+ if (rotation == 0) return bitmap;
1474
+ Matrix m = new Matrix();
1475
+ m.postRotate(rotation);
1476
+ Bitmap rotated = Bitmap.createBitmap(
1477
+ bitmap,
1478
+ 0,
1479
+ 0,
1480
+ bitmap.getWidth(),
1481
+ bitmap.getHeight(),
1482
+ m,
1483
+ true
1484
+ );
1485
+ if (rotated != bitmap) {
1486
+ try {
1487
+ bitmap.recycle();
1488
+ } catch (Exception ignore) {}
1489
+ }
1490
+ return rotated;
1491
+ } catch (Exception e) {
1492
+ return bitmap;
1493
+ }
1284
1494
  }
1285
1495
 
1286
1496
  private Bitmap resizeBitmapToMaxDimensions(
@@ -1292,7 +1502,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1292
1502
  int originalHeight = bitmap.getHeight();
1293
1503
  float originalAspectRatio = (float) originalWidth / originalHeight;
1294
1504
 
1295
- int targetWidth = originalWidth;
1505
+ int targetWidth;
1296
1506
  int targetHeight = originalHeight;
1297
1507
 
1298
1508
  if (maxWidth != null && maxHeight != null) {
@@ -1501,54 +1711,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1501
1711
  { ExifInterface.TAG_Y_RESOLUTION, "YResolution" },
1502
1712
  };
1503
1713
 
1504
- private byte[] writeExifToImageBytes(
1505
- byte[] imageBytes,
1506
- ExifInterface sourceExif
1507
- ) {
1508
- try {
1509
- // Create a temporary file to write the image with EXIF
1510
- File tempExifFile = File.createTempFile(
1511
- "temp_exif",
1512
- ".jpg",
1513
- context.getCacheDir()
1514
- );
1515
-
1516
- // Write the image bytes to temp file
1517
- java.io.FileOutputStream fos = new java.io.FileOutputStream(tempExifFile);
1518
- fos.write(imageBytes);
1519
- fos.close();
1520
-
1521
- // Create new ExifInterface for the temp file and copy all EXIF data
1522
- ExifInterface newExif = new ExifInterface(tempExifFile.getAbsolutePath());
1523
-
1524
- // Copy all EXIF attributes from source to new
1525
- for (String[] tag : EXIF_TAGS) {
1526
- String value = sourceExif.getAttribute(tag[0]);
1527
- if (value != null) {
1528
- newExif.setAttribute(tag[0], value);
1529
- }
1530
- }
1531
-
1532
- // Save the EXIF data
1533
- newExif.saveAttributes();
1534
-
1535
- // Read the file back with EXIF embedded
1536
- byte[] result = new byte[(int) tempExifFile.length()];
1537
- java.io.FileInputStream fis = new java.io.FileInputStream(tempExifFile);
1538
- fis.read(result);
1539
- fis.close();
1540
-
1541
- // Clean up temp file
1542
- tempExifFile.delete();
1543
-
1544
- return result;
1545
- } catch (Exception e) {
1546
- Log.e(TAG, "writeExifToImageBytes: Error writing EXIF data", e);
1547
- return imageBytes; // Return original bytes if error
1548
- }
1549
- }
1714
+ // Note: We avoid temporary files for EXIF writes. When we transform pixels (resize/crop),
1715
+ // we recompress JPEG in-memory and update EXIF info only in the returned JSON, not in the bytes.
1550
1716
 
1551
1717
  public void captureSample(int quality) {
1718
+ if (IsOperationRunning("captureSample")) {
1719
+ Log.d(TAG, "captureSample: Ignored because stop is pending");
1720
+ return;
1721
+ }
1552
1722
  Log.d(
1553
1723
  TAG,
1554
1724
  "captureSample: Starting sample capture with quality: " + quality
@@ -1572,10 +1742,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1572
1742
  "Sample capture failed: " + exception.getMessage()
1573
1743
  );
1574
1744
  }
1745
+ endOperation("captureSample");
1575
1746
  }
1576
1747
 
1577
1748
  @Override
1578
1749
  public void onCaptureSuccess(@NonNull ImageProxy image) {
1750
+ //noinspection TryFinallyCanBeTryWithResources
1579
1751
  try {
1580
1752
  // Convert ImageProxy to byte array
1581
1753
  byte[] bytes = imageProxyToByteArray(image);
@@ -1593,6 +1765,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1593
1765
  }
1594
1766
  } finally {
1595
1767
  image.close();
1768
+ endOperation("captureSample");
1596
1769
  }
1597
1770
  }
1598
1771
  }
@@ -1653,13 +1826,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1653
1826
  // not workin for xiaomi https://xiaomi.eu/community/threads/mi-11-ultra-unable-to-access-camera-lenses-in-apps-camera2-api.61456/
1654
1827
  @OptIn(markerClass = ExperimentalCamera2Interop.class)
1655
1828
  public static List<
1656
- com.ahm.capacitor.camera.preview.model.CameraDevice
1829
+ app.capgo.capacitor.camera.preview.model.CameraDevice
1657
1830
  > getAvailableDevicesStatic(Context context) {
1658
1831
  Log.d(
1659
1832
  TAG,
1660
1833
  "getAvailableDevicesStatic: Starting CameraX device enumeration with getPhysicalCameraInfos."
1661
1834
  );
1662
- List<com.ahm.capacitor.camera.preview.model.CameraDevice> devices =
1835
+ List<app.capgo.capacitor.camera.preview.model.CameraDevice> devices =
1663
1836
  new ArrayList<>();
1664
1837
  try {
1665
1838
  ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
@@ -1683,7 +1856,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1683
1856
  List<LensInfo> logicalLenses = new ArrayList<>();
1684
1857
  logicalLenses.add(new LensInfo(4.25f, "wideAngle", 1.0f, maxZoom));
1685
1858
  devices.add(
1686
- new com.ahm.capacitor.camera.preview.model.CameraDevice(
1859
+ new app.capgo.capacitor.camera.preview.model.CameraDevice(
1687
1860
  logicalCameraId,
1688
1861
  "Logical Camera (" + position + ")",
1689
1862
  position,
@@ -1782,7 +1955,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1782
1955
  );
1783
1956
 
1784
1957
  devices.add(
1785
- new com.ahm.capacitor.camera.preview.model.CameraDevice(
1958
+ new app.capgo.capacitor.camera.preview.model.CameraDevice(
1786
1959
  physicalId,
1787
1960
  label,
1788
1961
  position,
@@ -1930,6 +2103,29 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1930
2103
  }
1931
2104
 
1932
2105
  public void setFocus(float x, float y) throws Exception {
2106
+ // Ignore focus if capture/stop is in progress or view is gone
2107
+ synchronized (captureLock) {
2108
+ if (isCapturingPhoto || stopRequested) {
2109
+ Log.d(TAG, "setFocus: Ignored because capture/stop in progress");
2110
+ return;
2111
+ }
2112
+ }
2113
+ if (
2114
+ !isRunning ||
2115
+ camera == null ||
2116
+ previewView == null ||
2117
+ previewContainer == null
2118
+ ) {
2119
+ Log.d(
2120
+ TAG,
2121
+ "setFocus: Ignored because camera/view not ready or not running"
2122
+ );
2123
+ return;
2124
+ }
2125
+ if (IsOperationRunning("setFocus")) {
2126
+ Log.d(TAG, "setFocus: Ignored because stop is pending");
2127
+ return;
2128
+ }
1933
2129
  if (camera == null) {
1934
2130
  throw new Exception("Camera not initialized");
1935
2131
  }
@@ -1955,7 +2151,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1955
2151
  ExposureState state = camera.getCameraInfo().getExposureState();
1956
2152
  Range<Integer> range = state.getExposureCompensationRange();
1957
2153
  int zeroIdx = 0;
1958
- if (range != null && !range.contains(0)) {
2154
+ if (!range.contains(0)) {
1959
2155
  // Choose the closest index to 0 if 0 is not available
1960
2156
  zeroIdx = Math.abs(range.getLower()) < Math.abs(range.getUpper())
1961
2157
  ? range.getLower()
@@ -1978,9 +2174,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1978
2174
  // Only show focus indicator after validation passes
1979
2175
  float indicatorX = x * viewWidth;
1980
2176
  float indicatorY = y * viewHeight;
1981
- showFocusIndicator(indicatorX, indicatorY);
2177
+ final long indicatorToken;
2178
+ long indicatorToken1;
2179
+ try {
2180
+ indicatorToken1 = showFocusIndicator(indicatorX, indicatorY);
2181
+ } catch (Exception ignore) {
2182
+ // If we can't show the indicator (e.g., view is gone), still proceed with metering
2183
+ // Use current token so hide is a no-op later
2184
+ indicatorToken1 = focusIndicatorAnimationId;
2185
+ }
1982
2186
 
1983
2187
  // Create MeteringPoint using the preview view
2188
+ indicatorToken = indicatorToken1;
1984
2189
  MeteringPointFactory factory = previewView.getMeteringPointFactory();
1985
2190
  MeteringPoint point = factory.createPoint(x * viewWidth, y * viewHeight);
1986
2191
 
@@ -2026,6 +2231,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2026
2231
  if (currentFocusFuture == future && currentFocusFuture.isDone()) {
2027
2232
  currentFocusFuture = null;
2028
2233
  }
2234
+ hideFocusIndicator(indicatorToken);
2235
+ endOperation("setFocus");
2029
2236
  }
2030
2237
  },
2031
2238
  ContextCompat.getMainExecutor(context)
@@ -2033,6 +2240,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2033
2240
  } catch (Exception e) {
2034
2241
  currentFocusFuture = null;
2035
2242
  Log.e(TAG, "Failed to set focus: " + e.getMessage());
2243
+ hideFocusIndicator(indicatorToken);
2244
+ endOperation("setFocus");
2036
2245
  throw e;
2037
2246
  }
2038
2247
  }
@@ -2056,40 +2265,36 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2056
2265
  }
2057
2266
  String normalized = mode.toUpperCase(Locale.US);
2058
2267
 
2059
- try {
2060
- Camera2CameraControl c2 = Camera2CameraControl.from(
2061
- camera.getCameraControl()
2062
- );
2063
- switch (normalized) {
2064
- case "LOCK": {
2065
- CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2066
- .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, true)
2067
- .setCaptureRequestOption(
2068
- CaptureRequest.CONTROL_AE_MODE,
2069
- CaptureRequest.CONTROL_AE_MODE_ON
2070
- )
2071
- .build();
2072
- mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2073
- currentExposureMode = "LOCK";
2074
- break;
2075
- }
2076
- case "CONTINUOUS": {
2077
- CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2078
- .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, false)
2079
- .setCaptureRequestOption(
2080
- CaptureRequest.CONTROL_AE_MODE,
2081
- CaptureRequest.CONTROL_AE_MODE_ON
2082
- )
2083
- .build();
2084
- mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2085
- currentExposureMode = "CONTINUOUS";
2086
- break;
2087
- }
2088
- default:
2089
- throw new Exception("Unsupported exposure mode: " + mode);
2268
+ Camera2CameraControl c2 = Camera2CameraControl.from(
2269
+ camera.getCameraControl()
2270
+ );
2271
+ switch (normalized) {
2272
+ case "LOCK": {
2273
+ CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2274
+ .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, true)
2275
+ .setCaptureRequestOption(
2276
+ CaptureRequest.CONTROL_AE_MODE,
2277
+ CaptureRequest.CONTROL_AE_MODE_ON
2278
+ )
2279
+ .build();
2280
+ mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2281
+ currentExposureMode = "LOCK";
2282
+ break;
2090
2283
  }
2091
- } catch (Exception e) {
2092
- throw e;
2284
+ case "CONTINUOUS": {
2285
+ CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2286
+ .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, false)
2287
+ .setCaptureRequestOption(
2288
+ CaptureRequest.CONTROL_AE_MODE,
2289
+ CaptureRequest.CONTROL_AE_MODE_ON
2290
+ )
2291
+ .build();
2292
+ mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2293
+ currentExposureMode = "CONTINUOUS";
2294
+ break;
2295
+ }
2296
+ default:
2297
+ throw new Exception("Unsupported exposure mode: " + mode);
2093
2298
  }
2094
2299
  }
2095
2300
 
@@ -2100,9 +2305,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2100
2305
  ExposureState state = camera.getCameraInfo().getExposureState();
2101
2306
  Range<Integer> idxRange = state.getExposureCompensationRange();
2102
2307
  Rational step = state.getExposureCompensationStep();
2103
- float evStep = step != null
2104
- ? (float) step.getNumerator() / (float) step.getDenominator()
2105
- : 1.0f;
2308
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2106
2309
  float min = idxRange.getLower() * evStep;
2107
2310
  float max = idxRange.getUpper() * evStep;
2108
2311
  return new float[] { min, max, evStep };
@@ -2115,9 +2318,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2115
2318
  ExposureState state = camera.getCameraInfo().getExposureState();
2116
2319
  int idx = state.getExposureCompensationIndex();
2117
2320
  Rational step = state.getExposureCompensationStep();
2118
- float evStep = step != null
2119
- ? (float) step.getNumerator() / (float) step.getDenominator()
2120
- : 1.0f;
2321
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2121
2322
  return idx * evStep;
2122
2323
  }
2123
2324
 
@@ -2128,9 +2329,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2128
2329
  ExposureState state = camera.getCameraInfo().getExposureState();
2129
2330
  Range<Integer> idxRange = state.getExposureCompensationRange();
2130
2331
  Rational step = state.getExposureCompensationStep();
2131
- float evStep = step != null
2132
- ? (float) step.getNumerator() / (float) step.getDenominator()
2133
- : 1.0f;
2332
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2134
2333
  if (evStep <= 0f) evStep = 1.0f;
2135
2334
  int idx = Math.round(ev / evStep);
2136
2335
  // clamp
@@ -2139,13 +2338,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2139
2338
  camera.getCameraControl().setExposureCompensationIndex(idx);
2140
2339
  }
2141
2340
 
2142
- private void showFocusIndicator(float x, float y) {
2143
- if (disableFocusIndicator || sessionConfig.getDisableFocusIndicator()) {
2144
- return;
2145
- }
2341
+ private long showFocusIndicator(float x, float y) {
2342
+ // If preview is gone (e.g., stopping/closing), bail out safely
2146
2343
  if (previewContainer == null) {
2147
2344
  Log.w(TAG, "showFocusIndicator: previewContainer is null");
2148
- return;
2345
+ return focusIndicatorAnimationId;
2346
+ }
2347
+ if (sessionConfig.getDisableFocusIndicator()) {
2348
+ return focusIndicatorAnimationId;
2149
2349
  }
2150
2350
 
2151
2351
  // Check if container has been laid out
@@ -2155,17 +2355,24 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2155
2355
  "showFocusIndicator: previewContainer not laid out yet, posting to run after layout"
2156
2356
  );
2157
2357
  previewContainer.post(() -> showFocusIndicator(x, y));
2158
- return;
2358
+ return focusIndicatorAnimationId;
2159
2359
  }
2160
2360
 
2161
- // Remove any existing focus indicator and cancel its animation
2162
- if (focusIndicatorView != null) {
2163
- try {
2164
- focusIndicatorView.clearAnimation();
2165
- } catch (Exception ignore) {}
2166
- previewContainer.removeView(focusIndicatorView);
2167
- focusIndicatorView = null;
2168
- }
2361
+ // Remove any existing focus indicators (ensure only one is visible)
2362
+ try {
2363
+ for (int i = previewContainer.getChildCount() - 1; i >= 0; i--) {
2364
+ View child = previewContainer.getChildAt(i);
2365
+ CharSequence desc = child.getContentDescription();
2366
+ if (desc != null && FOCUS_INDICATOR_TAG.contentEquals(desc)) {
2367
+ previewContainer.removeViewAt(i);
2368
+ }
2369
+ }
2370
+ if (focusIndicatorView != null) {
2371
+ ViewGroup parent = (ViewGroup) focusIndicatorView.getParent();
2372
+ if (parent != null) parent.removeView(focusIndicatorView);
2373
+ focusIndicatorView = null;
2374
+ }
2375
+ } catch (Exception ignore) {}
2169
2376
 
2170
2377
  // Create an elegant focus indicator
2171
2378
  FrameLayout container = new FrameLayout(context);
@@ -2178,11 +2385,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2178
2385
 
2179
2386
  params.leftMargin = Math.max(
2180
2387
  0,
2181
- Math.min((int) (x - size / 2), containerWidth - size)
2388
+ Math.min((int) (x - (float) size / 2), containerWidth - size)
2182
2389
  );
2183
2390
  params.topMargin = Math.max(
2184
2391
  0,
2185
- Math.min((int) (y - size / 2), containerHeight - size)
2392
+ Math.min((int) (y - (float) size / 2), containerHeight - size)
2186
2393
  );
2187
2394
 
2188
2395
  // iOS Camera style: square with mid-edge ticks
@@ -2197,7 +2404,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2197
2404
  // Add 4 tiny mid-edge ticks inside the square
2198
2405
  int tickLen = (int) (12 *
2199
2406
  context.getResources().getDisplayMetrics().density);
2200
- int inset = stroke; // ticks should touch the sides
2407
+ // ticks should touch the sides
2201
2408
  // Top tick (perpendicular): vertical inward from top edge
2202
2409
  View topTick = new View(context);
2203
2410
  FrameLayout.LayoutParams topParams = new FrameLayout.LayoutParams(
@@ -2205,7 +2412,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2205
2412
  tickLen
2206
2413
  );
2207
2414
  topParams.leftMargin = (size - stroke) / 2;
2208
- topParams.topMargin = inset;
2415
+ topParams.topMargin = stroke;
2209
2416
  topTick.setLayoutParams(topParams);
2210
2417
  topTick.setBackgroundColor(Color.YELLOW);
2211
2418
  container.addView(topTick);
@@ -2216,7 +2423,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2216
2423
  tickLen
2217
2424
  );
2218
2425
  bottomParams.leftMargin = (size - stroke) / 2;
2219
- bottomParams.topMargin = size - inset - tickLen;
2426
+ bottomParams.topMargin = size - stroke - tickLen;
2220
2427
  bottomTick.setLayoutParams(bottomParams);
2221
2428
  bottomTick.setBackgroundColor(Color.YELLOW);
2222
2429
  container.addView(bottomTick);
@@ -2226,7 +2433,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2226
2433
  tickLen,
2227
2434
  stroke
2228
2435
  );
2229
- leftParams.leftMargin = inset;
2436
+ leftParams.leftMargin = stroke;
2230
2437
  leftParams.topMargin = (size - stroke) / 2;
2231
2438
  leftTick.setLayoutParams(leftParams);
2232
2439
  leftTick.setBackgroundColor(Color.YELLOW);
@@ -2237,21 +2444,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2237
2444
  tickLen,
2238
2445
  stroke
2239
2446
  );
2240
- rightParams.leftMargin = size - inset - tickLen;
2447
+ rightParams.leftMargin = size - stroke - tickLen;
2241
2448
  rightParams.topMargin = (size - stroke) / 2;
2242
2449
  rightTick.setLayoutParams(rightParams);
2243
2450
  rightTick.setBackgroundColor(Color.YELLOW);
2244
2451
  container.addView(rightTick);
2245
2452
 
2453
+ container.setContentDescription(FOCUS_INDICATOR_TAG);
2246
2454
  focusIndicatorView = container;
2247
2455
  // Bump animation token; everything after this must validate against this token
2248
2456
  final long thisAnimationId = ++focusIndicatorAnimationId;
2249
2457
  final View thisIndicatorView = focusIndicatorView;
2250
2458
 
2251
- // Set initial state for smooth animation (mirror iOS)
2252
- focusIndicatorView.setAlpha(0f);
2253
- focusIndicatorView.setScaleX(1.5f);
2254
- focusIndicatorView.setScaleY(1.5f);
2459
+ // Show immediately (avoid complex animations that can race with teardown)
2460
+ focusIndicatorView.setAlpha(1f);
2461
+ focusIndicatorView.setScaleX(1f);
2462
+ focusIndicatorView.setScaleY(1f);
2255
2463
  focusIndicatorView.setVisibility(View.VISIBLE);
2256
2464
 
2257
2465
  // Ensure container doesn't intercept touch events
@@ -2259,12 +2467,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2259
2467
  container.setFocusable(false);
2260
2468
 
2261
2469
  // Ensure the focus indicator has a high elevation for visibility
2262
- if (
2263
- android.os.Build.VERSION.SDK_INT >=
2264
- android.os.Build.VERSION_CODES.LOLLIPOP
2265
- ) {
2266
- focusIndicatorView.setElevation(10f);
2267
- }
2470
+ focusIndicatorView.setElevation(10f);
2268
2471
 
2269
2472
  // Add to container first
2270
2473
  previewContainer.addView(focusIndicatorView, params);
@@ -2275,88 +2478,43 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2275
2478
  // Force a layout pass to ensure the view is properly positioned
2276
2479
  previewContainer.requestLayout();
2277
2480
 
2278
- // First phase: fade in and scale to 1.0 over 150ms
2279
- focusIndicatorView
2280
- .animate()
2281
- .alpha(1f)
2282
- .scaleX(1f)
2283
- .scaleY(1f)
2284
- .setDuration(150)
2285
- .start();
2286
-
2287
- // Phase 2: after 500ms, fade to 0.3 over 200ms
2288
- focusIndicatorView.postDelayed(
2289
- new Runnable() {
2290
- @Override
2291
- public void run() {
2292
- // Ensure this runnable belongs to the latest indicator
2481
+ // Do not schedule delayed cleanup; indicator will be removed when focus completes
2482
+ return thisAnimationId;
2483
+ }
2484
+
2485
+ private void hideFocusIndicator(long token) {
2486
+ // If we're stopping or not running anymore, don't attempt to touch the view tree
2487
+ if (stopRequested || !isRunning) {
2488
+ focusIndicatorView = null;
2489
+ return;
2490
+ }
2491
+ try {
2492
+ mainExecutor.execute(() -> {
2493
+ try {
2293
2494
  if (
2294
- focusIndicatorView != null &&
2295
- thisIndicatorView == focusIndicatorView &&
2296
- thisAnimationId == focusIndicatorAnimationId
2495
+ focusIndicatorView == null ||
2496
+ previewContainer == null ||
2497
+ token != focusIndicatorAnimationId
2297
2498
  ) {
2298
- focusIndicatorView
2299
- .animate()
2300
- .alpha(0.3f)
2301
- .setDuration(200)
2302
- .withEndAction(
2303
- new Runnable() {
2304
- @Override
2305
- public void run() {
2306
- // Phase 3: after 200ms more, fade out to 0 and scale to 0.8 over 300ms
2307
- focusIndicatorView.postDelayed(
2308
- new Runnable() {
2309
- @Override
2310
- public void run() {
2311
- if (
2312
- focusIndicatorView != null &&
2313
- thisIndicatorView == focusIndicatorView &&
2314
- thisAnimationId == focusIndicatorAnimationId
2315
- ) {
2316
- focusIndicatorView
2317
- .animate()
2318
- .alpha(0f)
2319
- .scaleX(0.8f)
2320
- .scaleY(0.8f)
2321
- .setDuration(300)
2322
- .setInterpolator(
2323
- new android.view.animation.AccelerateInterpolator()
2324
- )
2325
- .withEndAction(
2326
- new Runnable() {
2327
- @Override
2328
- public void run() {
2329
- if (
2330
- focusIndicatorView != null &&
2331
- previewContainer != null &&
2332
- thisIndicatorView == focusIndicatorView &&
2333
- thisAnimationId ==
2334
- focusIndicatorAnimationId
2335
- ) {
2336
- try {
2337
- focusIndicatorView.clearAnimation();
2338
- } catch (Exception ignore) {}
2339
- previewContainer.removeView(
2340
- focusIndicatorView
2341
- );
2342
- focusIndicatorView = null;
2343
- }
2344
- }
2345
- }
2346
- );
2347
- }
2348
- }
2349
- },
2350
- 200
2351
- );
2352
- }
2353
- }
2354
- );
2499
+ return;
2355
2500
  }
2501
+ // If the view hierarchy is already being torn down, skip safely
2502
+ if (!previewContainer.isAttachedToWindow()) {
2503
+ focusIndicatorView = null;
2504
+ return;
2505
+ }
2506
+ ViewGroup parent = (ViewGroup) focusIndicatorView.getParent();
2507
+ if (parent != null) {
2508
+ parent.removeView(focusIndicatorView);
2509
+ }
2510
+ } catch (Exception ignore) {} finally {
2511
+ focusIndicatorView = null;
2356
2512
  }
2357
- },
2358
- 500
2359
- );
2513
+ });
2514
+ } catch (Exception ignore) {
2515
+ // Executor or Looper not available; just null out the reference
2516
+ focusIndicatorView = null;
2517
+ }
2360
2518
  }
2361
2519
 
2362
2520
  public static List<Size> getSupportedPictureSizes(String facing) {
@@ -2562,7 +2720,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2562
2720
  sessionConfig.isToBack(), // toBack
2563
2721
  sessionConfig.isStoreToFile(), // storeToFile
2564
2722
  sessionConfig.isEnableOpacity(), // enableOpacity
2565
- sessionConfig.isEnableZoom(), // enableZoom
2566
2723
  sessionConfig.isDisableExifHeaderStripping(), // disableExifHeaderStripping
2567
2724
  sessionConfig.isDisableAudio(), // disableAudio
2568
2725
  sessionConfig.getZoomFactor(), // zoomFactor
@@ -2722,7 +2879,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2722
2879
  sessionConfig.getToBack(),
2723
2880
  sessionConfig.getStoreToFile(),
2724
2881
  sessionConfig.getEnableOpacity(),
2725
- sessionConfig.getEnableZoom(),
2726
2882
  sessionConfig.getDisableExifHeaderStripping(),
2727
2883
  sessionConfig.getDisableAudio(),
2728
2884
  sessionConfig.getZoomFactor(),
@@ -2737,7 +2893,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2737
2893
  if (isRunning && previewContainer != null) {
2738
2894
  mainExecutor.execute(() -> {
2739
2895
  // First update the UI layout - always pass null for x,y to force auto-centering (matching iOS)
2740
- updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
2896
+ updatePreviewLayoutForAspectRatio(aspectRatio);
2741
2897
 
2742
2898
  // Then rebind the camera with new aspect ratio configuration
2743
2899
  Log.d(
@@ -2826,7 +2982,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2826
2982
  sessionConfig.getToBack(),
2827
2983
  sessionConfig.getStoreToFile(),
2828
2984
  sessionConfig.getEnableOpacity(),
2829
- sessionConfig.getEnableZoom(),
2830
2985
  sessionConfig.getDisableExifHeaderStripping(),
2831
2986
  sessionConfig.getDisableAudio(),
2832
2987
  sessionConfig.getZoomFactor(),
@@ -2841,7 +2996,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2841
2996
  if (isRunning && previewContainer != null) {
2842
2997
  mainExecutor.execute(() -> {
2843
2998
  // First update the UI layout - always pass null for x,y to force auto-centering (matching iOS)
2844
- updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
2999
+ updatePreviewLayoutForAspectRatio(aspectRatio);
2845
3000
 
2846
3001
  // Then rebind the camera with new aspect ratio configuration
2847
3002
  Log.d(
@@ -2902,7 +3057,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2902
3057
  sessionConfig.getToBack(),
2903
3058
  sessionConfig.getStoreToFile(),
2904
3059
  sessionConfig.getEnableOpacity(),
2905
- sessionConfig.getEnableZoom(),
2906
3060
  sessionConfig.getDisableExifHeaderStripping(),
2907
3061
  sessionConfig.getDisableAudio(),
2908
3062
  sessionConfig.getZoomFactor(),
@@ -3010,6 +3164,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3010
3164
  // Swap dimensions if in portrait mode to match how PreviewView displays it
3011
3165
  if (isPortrait) {
3012
3166
  int temp = cameraWidth;
3167
+ //noinspection SuspiciousNameCombination,ReassignedVariable
3013
3168
  cameraWidth = cameraHeight;
3014
3169
  cameraHeight = temp;
3015
3170
  }
@@ -3286,7 +3441,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3286
3441
  sessionConfig.getToBack(),
3287
3442
  sessionConfig.getStoreToFile(),
3288
3443
  sessionConfig.getEnableOpacity(),
3289
- sessionConfig.getEnableZoom(),
3290
3444
  sessionConfig.getDisableExifHeaderStripping(),
3291
3445
  sessionConfig.getDisableAudio(),
3292
3446
  sessionConfig.getZoomFactor(),
@@ -3318,7 +3472,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3318
3472
  previewContainer.post(callback);
3319
3473
  });
3320
3474
  } else {
3321
- previewContainer.post(() -> updateGridOverlayBounds());
3475
+ previewContainer.post(this::updateGridOverlayBounds);
3322
3476
  }
3323
3477
  } else {
3324
3478
  // No camera rebinding needed, wait for layout to complete then call callback
@@ -3359,29 +3513,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3359
3513
  }
3360
3514
 
3361
3515
  private void updatePreviewLayoutForAspectRatio(String aspectRatio) {
3362
- updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
3363
- }
3364
-
3365
- private void updatePreviewLayoutForAspectRatio(
3366
- String aspectRatio,
3367
- Float x,
3368
- Float y
3369
- ) {
3370
3516
  if (previewContainer == null || aspectRatio == null) return;
3371
3517
 
3372
3518
  Log.d(
3373
3519
  TAG,
3374
3520
  "======================== UPDATE PREVIEW LAYOUT FOR ASPECT RATIO ========================"
3375
3521
  );
3376
- Log.d(
3377
- TAG,
3378
- "Input parameters - aspectRatio: " +
3379
- aspectRatio +
3380
- ", x: " +
3381
- x +
3382
- ", y: " +
3383
- y
3384
- );
3522
+ Log.d(TAG, "Input parameters - aspectRatio: " + aspectRatio);
3385
3523
 
3386
3524
  // Get comprehensive display information
3387
3525
  WindowManager windowManager = (WindowManager) this.context.getSystemService(
@@ -3491,115 +3629,57 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3491
3629
  );
3492
3630
 
3493
3631
  // Get available space from webview dimensions
3494
- int availableWidth = webViewWidth;
3495
- int availableHeight = webViewHeight;
3496
3632
 
3497
3633
  Log.d(
3498
3634
  TAG,
3499
- "Available space from WebView: " +
3500
- availableWidth +
3501
- "x" +
3502
- availableHeight
3635
+ "Available space from WebView: " + webViewWidth + "x" + webViewHeight
3503
3636
  );
3504
3637
 
3505
3638
  // Calculate position and size
3506
3639
  int finalX, finalY, finalWidth, finalHeight;
3640
+ // Auto-center mode - match iOS behavior exactly
3641
+ Log.d(TAG, "Auto-center mode");
3507
3642
 
3508
- if (x != null && y != null) {
3509
- // Manual positioning mode
3510
- int webViewTopInset = getWebViewTopInset();
3511
- int webViewLeftInset = getWebViewLeftInset();
3512
-
3513
- Log.d(
3514
- TAG,
3515
- "Manual positioning mode - WebView insets: left=" +
3516
- webViewLeftInset +
3517
- ", top=" +
3518
- webViewTopInset
3519
- );
3520
-
3521
- finalX = Math.max(
3522
- 0,
3523
- Math.min(x.intValue() + webViewLeftInset, availableWidth)
3524
- );
3525
- finalY = Math.max(
3526
- 0,
3527
- Math.min(y.intValue() + webViewTopInset, availableHeight)
3528
- );
3529
-
3530
- // Calculate maximum available space from the given position
3531
- int maxWidth = availableWidth - finalX;
3532
- int maxHeight = availableHeight - finalY;
3643
+ // Calculate maximum size that fits the aspect ratio in available space
3644
+ double maxWidthByHeight = webViewHeight * ratio;
3645
+ double maxHeightByWidth = webViewWidth / ratio;
3533
3646
 
3534
- Log.d(
3535
- TAG,
3536
- "Max available space from position: " + maxWidth + "x" + maxHeight
3537
- );
3538
-
3539
- // Calculate optimal size while maintaining aspect ratio within available space
3540
- finalWidth = maxWidth;
3541
- finalHeight = (int) (maxWidth / ratio);
3542
-
3543
- if (finalHeight > maxHeight) {
3544
- // Height constraint is tighter, fit by height
3545
- finalHeight = maxHeight;
3546
- finalWidth = (int) (maxHeight * ratio);
3547
- Log.d(TAG, "Height-constrained sizing");
3548
- } else {
3549
- Log.d(TAG, "Width-constrained sizing");
3550
- }
3647
+ Log.d(
3648
+ TAG,
3649
+ "Aspect ratio calculations - maxWidthByHeight: " +
3650
+ maxWidthByHeight +
3651
+ ", maxHeightByWidth: " +
3652
+ maxHeightByWidth
3653
+ );
3551
3654
 
3552
- // Ensure final position stays within bounds
3553
- finalX = Math.max(0, Math.min(finalX, availableWidth - finalWidth));
3554
- finalY = Math.max(0, Math.min(finalY, availableHeight - finalHeight));
3655
+ if (maxWidthByHeight <= webViewWidth) {
3656
+ // Height is the limiting factor
3657
+ finalWidth = (int) maxWidthByHeight;
3658
+ finalHeight = webViewHeight;
3659
+ Log.d(TAG, "Height-limited sizing: " + finalWidth + "x" + finalHeight);
3555
3660
  } else {
3556
- // Auto-center mode - match iOS behavior exactly
3557
- Log.d(TAG, "Auto-center mode");
3558
-
3559
- // Calculate maximum size that fits the aspect ratio in available space
3560
- double maxWidthByHeight = availableHeight * ratio;
3561
- double maxHeightByWidth = availableWidth / ratio;
3562
-
3563
- Log.d(
3564
- TAG,
3565
- "Aspect ratio calculations - maxWidthByHeight: " +
3566
- maxWidthByHeight +
3567
- ", maxHeightByWidth: " +
3568
- maxHeightByWidth
3569
- );
3570
-
3571
- if (maxWidthByHeight <= availableWidth) {
3572
- // Height is the limiting factor
3573
- finalWidth = (int) maxWidthByHeight;
3574
- finalHeight = availableHeight;
3575
- Log.d(
3576
- TAG,
3577
- "Height-limited sizing: " + finalWidth + "x" + finalHeight
3578
- );
3579
- } else {
3580
- // Width is the limiting factor
3581
- finalWidth = availableWidth;
3582
- finalHeight = (int) maxHeightByWidth;
3583
- Log.d(TAG, "Width-limited sizing: " + finalWidth + "x" + finalHeight);
3584
- }
3661
+ // Width is the limiting factor
3662
+ finalWidth = webViewWidth;
3663
+ finalHeight = (int) maxHeightByWidth;
3664
+ Log.d(TAG, "Width-limited sizing: " + finalWidth + "x" + finalHeight);
3665
+ }
3585
3666
 
3586
- // Center the preview
3587
- finalX = (availableWidth - finalWidth) / 2;
3588
- finalY = (availableHeight - finalHeight) / 2;
3667
+ // Center the preview
3668
+ finalX = (webViewWidth - finalWidth) / 2;
3669
+ finalY = (webViewHeight - finalHeight) / 2;
3589
3670
 
3590
- Log.d(
3591
- TAG,
3592
- "Auto-center mode: calculated size " +
3593
- finalWidth +
3594
- "x" +
3595
- finalHeight +
3596
- " at position (" +
3597
- finalX +
3598
- ", " +
3599
- finalY +
3600
- ")"
3601
- );
3602
- }
3671
+ Log.d(
3672
+ TAG,
3673
+ "Auto-center mode: calculated size " +
3674
+ finalWidth +
3675
+ "x" +
3676
+ finalHeight +
3677
+ " at position (" +
3678
+ finalX +
3679
+ ", " +
3680
+ finalY +
3681
+ ")"
3682
+ );
3603
3683
 
3604
3684
  Log.d(
3605
3685
  TAG,
@@ -3627,24 +3707,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3627
3707
  );
3628
3708
 
3629
3709
  // Compare with expected ratio based on orientation
3630
- if (aspectRatio != null) {
3631
- String[] parts = aspectRatio.split(":");
3632
- if (parts.length == 2) {
3633
- double expectedDisplayRatio = isPortrait
3634
- ? (ratioHeight / ratioWidth)
3635
- : (ratioWidth / ratioHeight);
3636
- double difference = Math.abs(displayedRatio - expectedDisplayRatio);
3637
- Log.d(
3638
- TAG,
3639
- "Display ratio check - Expected: " +
3640
- expectedDisplayRatio +
3641
- ", Actual: " +
3642
- displayedRatio +
3643
- ", Difference: " +
3644
- difference +
3645
- " (tolerance should be < 0.01)"
3646
- );
3647
- }
3710
+ String[] parts = aspectRatio.split(":");
3711
+ if (parts.length == 2) {
3712
+ double expectedDisplayRatio = isPortrait
3713
+ ? (ratioHeight / ratioWidth)
3714
+ : (ratioWidth / ratioHeight);
3715
+ double difference = Math.abs(displayedRatio - expectedDisplayRatio);
3716
+ Log.d(
3717
+ TAG,
3718
+ "Display ratio check - Expected: " +
3719
+ expectedDisplayRatio +
3720
+ ", Actual: " +
3721
+ displayedRatio +
3722
+ ", Difference: " +
3723
+ difference +
3724
+ " (tolerance should be < 0.01)"
3725
+ );
3648
3726
  }
3649
3727
 
3650
3728
  // Update layout params
@@ -3796,93 +3874,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3796
3874
 
3797
3875
  Log.d(
3798
3876
  TAG,
3799
- "updateGridOverlayBounds: Updated grid bounds to " +
3800
- cameraBounds.toString()
3877
+ "updateGridOverlayBounds: Updated grid bounds to " + cameraBounds
3801
3878
  );
3802
3879
  }
3803
3880
  }
3804
3881
 
3805
- private void triggerAutoFocus() {
3806
- if (camera == null) {
3807
- return;
3808
- }
3809
-
3810
- Log.d(TAG, "triggerAutoFocus: Triggering autofocus at center");
3811
-
3812
- // Cancel any ongoing focus operation
3813
- if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
3814
- Log.d(TAG, "triggerAutoFocus: Cancelling previous focus operation");
3815
- currentFocusFuture.cancel(true);
3816
- }
3817
-
3818
- // Focus on the center of the view
3819
- int viewWidth = previewView.getWidth();
3820
- int viewHeight = previewView.getHeight();
3821
-
3822
- if (viewWidth == 0 || viewHeight == 0) {
3823
- return;
3824
- }
3825
-
3826
- // Create MeteringPoint at the center of the preview
3827
- MeteringPointFactory factory = previewView.getMeteringPointFactory();
3828
- MeteringPoint point = factory.createPoint(viewWidth / 2f, viewHeight / 2f);
3829
-
3830
- // Create focus and metering action (persistent, no auto-cancel) to match iOS behavior
3831
- FocusMeteringAction action = new FocusMeteringAction.Builder(
3832
- point,
3833
- FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE
3834
- )
3835
- .disableAutoCancel()
3836
- .build();
3837
-
3838
- try {
3839
- currentFocusFuture = camera
3840
- .getCameraControl()
3841
- .startFocusAndMetering(action);
3842
- currentFocusFuture.addListener(
3843
- () -> {
3844
- try {
3845
- FocusMeteringResult result = currentFocusFuture.get();
3846
- Log.d(
3847
- TAG,
3848
- "triggerAutoFocus: Focus completed successfully: " +
3849
- result.isFocusSuccessful()
3850
- );
3851
- } catch (Exception e) {
3852
- // Handle cancellation gracefully - this is expected when rapid operations occur
3853
- if (
3854
- e.getMessage() != null &&
3855
- (e
3856
- .getMessage()
3857
- .contains("Cancelled by another startFocusAndMetering") ||
3858
- e.getMessage().contains("OperationCanceledException") ||
3859
- e
3860
- .getClass()
3861
- .getSimpleName()
3862
- .contains("OperationCanceledException"))
3863
- ) {
3864
- Log.d(
3865
- TAG,
3866
- "triggerAutoFocus: Auto-focus was cancelled by a newer focus request"
3867
- );
3868
- } else {
3869
- Log.e(TAG, "triggerAutoFocus: Error during focus", e);
3870
- }
3871
- } finally {
3872
- // Clear the reference if this is still the current operation
3873
- if (currentFocusFuture != null && currentFocusFuture.isDone()) {
3874
- currentFocusFuture = null;
3875
- }
3876
- }
3877
- },
3878
- ContextCompat.getMainExecutor(context)
3879
- );
3880
- } catch (Exception e) {
3881
- currentFocusFuture = null;
3882
- Log.e(TAG, "triggerAutoFocus: Failed to trigger autofocus", e);
3883
- }
3884
- }
3885
-
3882
+ /** @noinspection ResultOfMethodCallIgnored*/
3886
3883
  public void startRecordVideo() throws Exception {
3887
3884
  if (videoCapture == null) {
3888
3885
  throw new Exception("VideoCapture is not initialized");
@@ -3921,6 +3918,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3921
3918
 
3922
3919
  // Start recording
3923
3920
  if (sessionConfig != null && !sessionConfig.isDisableAudio()) {
3921
+ if (
3922
+ ActivityCompat.checkSelfPermission(
3923
+ context,
3924
+ Manifest.permission.RECORD_AUDIO
3925
+ ) !=
3926
+ PackageManager.PERMISSION_GRANTED
3927
+ ) {
3928
+ return;
3929
+ }
3924
3930
  currentRecording = videoCapture
3925
3931
  .getOutput()
3926
3932
  .prepareRecording(context, outputOptions)
@@ -3945,60 +3951,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3945
3951
  );
3946
3952
  }
3947
3953
 
3948
- private void initializeVideoCapture() throws Exception {
3949
- if (isVideoCaptureInitializing) {
3950
- throw new Exception("VideoCapture initialization is already in progress");
3951
- }
3952
- if (cameraProvider == null || camera == null) {
3953
- throw new Exception("Camera is not initialized");
3954
- }
3955
-
3956
- isVideoCaptureInitializing = true;
3957
-
3958
- try {
3959
- // Get current rotation for video capture
3960
- int rotation = previewView != null && previewView.getDisplay() != null
3961
- ? previewView.getDisplay().getRotation()
3962
- : android.view.Surface.ROTATION_0;
3963
-
3964
- // Setup VideoCapture with rotation and quality fallback
3965
- QualitySelector qualitySelector = QualitySelector.fromOrderedList(
3966
- Arrays.asList(Quality.FHD, Quality.HD, Quality.SD),
3967
- FallbackStrategy.higherQualityOrLowerThan(Quality.FHD)
3968
- );
3969
- Recorder recorder = new Recorder.Builder()
3970
- .setQualitySelector(qualitySelector)
3971
- .build();
3972
- videoCapture = VideoCapture.withOutput(recorder);
3973
-
3974
- // Reuse the Preview use case we created during initial binding
3975
- Preview preview = previewUseCase;
3976
-
3977
- // Rebind with video capture included
3978
- cameraProvider.unbindAll();
3979
- if (preview != null) {
3980
- // CRITICAL: Re-set the surface provider after unbinding
3981
- // Without this, the preview won't be connected to the surface and video won't be captured
3982
- preview.setSurfaceProvider(previewView.getSurfaceProvider());
3983
-
3984
- camera = cameraProvider.bindToLifecycle(
3985
- this,
3986
- currentCameraSelector,
3987
- preview,
3988
- imageCapture,
3989
- videoCapture
3990
- );
3991
- } else {
3992
- // Shouldn't happen, but handle gracefully
3993
- throw new Exception("Preview use case not found");
3994
- }
3995
-
3996
- Log.d(TAG, "VideoCapture initialized successfully");
3997
- } finally {
3998
- isVideoCaptureInitializing = false;
3999
- }
4000
- }
4001
-
4002
3954
  public void stopRecordVideo(VideoRecordingCallback callback) {
4003
3955
  if (currentRecording == null) {
4004
3956
  callback.onError("No video recording in progress");