@capgo/camera-preview 7.14.8 → 7.16.1

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,85 @@ 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
+ // No EXIF rewrite here; bytes already contain EXIF when needed
363
+
364
+ // Notify the gallery of the new image
365
+ MediaScannerConnection.scanFile(
366
+ this.context,
367
+ new String[] { photo.getAbsolutePath() },
368
+ new String[] { mimeType },
369
+ null
370
+ );
371
+ } catch (IOException e) {
372
+ Log.e(TAG, "Error saving image to gallery (with exif)", e);
373
+ }
374
+ }
375
+
272
376
  public void startSession(CameraSessionConfiguration config) {
273
377
  this.sessionConfig = config;
274
378
  cameraExecutor = Executors.newSingleThreadExecutor();
379
+ synchronized (operationLock) {
380
+ activeOperations = 0;
381
+ stopPending = false;
382
+ }
275
383
  mainExecutor.execute(() -> {
276
384
  lifecycleRegistry.setCurrentState(Lifecycle.State.STARTED);
277
385
  setupCamera();
@@ -279,26 +387,38 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
279
387
  }
280
388
 
281
389
  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
- }
390
+ // Mark stop pending; reject new operations and wait for active ones to finish
391
+ synchronized (operationLock) {
392
+ stopPending = true;
393
+ }
394
+ stopRequested = true;
395
+
396
+ boolean hasOps;
397
+ synchronized (operationLock) {
398
+ hasOps = activeOperations > 0;
399
+ }
400
+ if (hasOps) {
401
+ // Detach preview so UI can close
402
+ if (!previewDetachedOnDeferredStop) {
403
+ mainExecutor.execute(() -> {
404
+ try {
405
+ if (previewContainer != null) {
406
+ ViewGroup parent = (ViewGroup) previewContainer.getParent();
407
+ if (parent != null) {
408
+ parent.removeView(previewContainer);
295
409
  }
296
- previewDetachedOnDeferredStop = true;
297
- } catch (Exception ignored) {}
298
- });
299
- }
300
- return;
410
+ }
411
+ previewDetachedOnDeferredStop = true;
412
+ } catch (Exception ignored) {}
413
+ });
301
414
  }
415
+ // Cancel focus to hasten completion
416
+ if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
417
+ try {
418
+ currentFocusFuture.cancel(true);
419
+ } catch (Exception ignored) {}
420
+ }
421
+ return;
302
422
  }
303
423
 
304
424
  performImmediateStop();
@@ -328,6 +448,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
328
448
  } finally {
329
449
  stopRequested = false;
330
450
  previewDetachedOnDeferredStop = false;
451
+ synchronized (operationLock) {
452
+ activeOperations = 0;
453
+ stopPending = false;
454
+ }
331
455
  if (listener != null) {
332
456
  try {
333
457
  listener.onCameraStopped();
@@ -381,6 +505,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
381
505
 
382
506
  // Create and setup the preview view
383
507
  previewView = new PreviewView(context);
508
+ // Use TextureView-backed implementation for broader device compatibility when overlaying with WebView
509
+ // This avoids SurfaceView z-order issues seen on some MIUI/EMUI devices.
510
+ previewView.setImplementationMode(
511
+ PreviewView.ImplementationMode.COMPATIBLE
512
+ );
384
513
  // Match iOS behavior: FIT when no aspect ratio, FILL when aspect ratio is set
385
514
  String initialAspectRatio = sessionConfig != null
386
515
  ? sessionConfig.getAspectRatio()
@@ -394,58 +523,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
394
523
  previewView.setClickable(true);
395
524
  previewView.setFocusable(true);
396
525
 
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);
526
+ // Intentionally no native gesture handling (tap-to-focus, pinch-to-zoom)
527
+ // Focus and zoom are controlled exclusively via JS API calls for parity with iOS.
449
528
 
450
529
  previewContainer.addView(
451
530
  previewView,
@@ -578,6 +657,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
578
657
  int webViewHeight = webView != null ? webView.getHeight() : 0;
579
658
 
580
659
  // Get parent dimensions
660
+ assert webView != null;
581
661
  ViewGroup parent = (ViewGroup) webView.getParent();
582
662
  int parentWidth = parent != null ? parent.getWidth() : 0;
583
663
  int parentHeight = parent != null ? parent.getHeight() : 0;
@@ -663,20 +743,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
663
743
  );
664
744
 
665
745
  // For centered mode with aspect ratio, calculate maximum size that fits
666
- int availableWidth = screenWidthPx;
667
- int availableHeight = screenHeightPx;
668
746
 
669
747
  Log.d(
670
748
  TAG,
671
749
  "Available space for preview: " +
672
- availableWidth +
750
+ screenWidthPx +
673
751
  "x" +
674
- availableHeight
752
+ screenHeightPx
675
753
  );
676
754
 
677
755
  // Calculate maximum size that fits the aspect ratio in available space
678
- double maxWidthByHeight = availableHeight * ratio;
679
- double maxHeightByWidth = availableWidth / ratio;
756
+ double maxWidthByHeight = screenHeightPx * ratio;
757
+ double maxHeightByWidth = screenWidthPx / ratio;
680
758
 
681
759
  Log.d(
682
760
  TAG,
@@ -686,21 +764,21 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
686
764
  maxHeightByWidth
687
765
  );
688
766
 
689
- if (maxWidthByHeight <= availableWidth) {
767
+ if (maxWidthByHeight <= screenWidthPx) {
690
768
  // Height is the limiting factor
691
769
  width = (int) maxWidthByHeight;
692
- height = availableHeight;
770
+ height = screenHeightPx;
693
771
  Log.d(TAG, "Height-limited sizing: " + width + "x" + height);
694
772
  } else {
695
773
  // Width is the limiting factor
696
- width = availableWidth;
774
+ width = screenWidthPx;
697
775
  height = (int) maxHeightByWidth;
698
776
  Log.d(TAG, "Width-limited sizing: " + width + "x" + height);
699
777
  }
700
778
 
701
779
  // Center the preview
702
- x = (availableWidth - width) / 2;
703
- y = (availableHeight - height) / 2;
780
+ x = (screenWidthPx - width) / 2;
781
+ y = (screenHeightPx - height) / 2;
704
782
 
705
783
  Log.d(TAG, "Auto-centered position: x=" + x + ", y=" + y);
706
784
  } catch (NumberFormatException e) {
@@ -805,7 +883,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
805
883
  .setTargetRotation(rotation)
806
884
  .build();
807
885
  // Keep reference to preview use case for later re-binding (e.g., when enabling video)
808
- previewUseCase = preview;
809
886
  imageCapture = new ImageCapture.Builder()
810
887
  .setResolutionSelector(resolutionSelector)
811
888
  .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
@@ -1062,25 +1139,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1062
1139
  return builder.build();
1063
1140
  }
1064
1141
 
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
1142
  private static boolean isBackCamera(
1085
1143
  androidx.camera.core.CameraInfo cameraInfo
1086
1144
  ) {
@@ -1108,6 +1166,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1108
1166
  Integer height,
1109
1167
  Location location
1110
1168
  ) {
1169
+ // Prevent capture if a stop is pending
1170
+ if (IsOperationRunning("capturePhoto")) {
1171
+ Log.d(TAG, "capturePhoto: Ignored because stop is pending");
1172
+ return;
1173
+ }
1111
1174
  Log.d(
1112
1175
  TAG,
1113
1176
  "capturePhoto: Starting photo capture with quality: " +
@@ -1129,9 +1192,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1129
1192
  isCapturingPhoto = true;
1130
1193
  }
1131
1194
 
1132
- File tempFile = new File(context.getCacheDir(), "temp_image.jpg");
1195
+ ByteArrayOutputStream imageStream = new ByteArrayOutputStream();
1196
+ ImageCapture.Metadata metadata = new ImageCapture.Metadata();
1197
+ if (location != null) {
1198
+ metadata.setLocation(location);
1199
+ }
1133
1200
  ImageCapture.OutputFileOptions outputFileOptions =
1134
- new ImageCapture.OutputFileOptions.Builder(tempFile).build();
1201
+ new ImageCapture.OutputFileOptions.Builder(imageStream)
1202
+ .setMetadata(metadata)
1203
+ .build();
1135
1204
 
1136
1205
  imageCapture.takePicture(
1137
1206
  outputFileOptions,
@@ -1152,6 +1221,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1152
1221
  performImmediateStop();
1153
1222
  }
1154
1223
  }
1224
+ endOperation("capturePhoto");
1155
1225
  }
1156
1226
 
1157
1227
  @Override
@@ -1159,28 +1229,25 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1159
1229
  @NonNull ImageCapture.OutputFileResults output
1160
1230
  ) {
1161
1231
  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();
1232
+ byte[] originalCaptureBytes = imageStream.toByteArray();
1233
+ byte[] bytes = originalCaptureBytes; // will be replaced if we transform
1234
+ int finalWidthOut = -1;
1235
+ int finalHeightOut = -1;
1236
+ boolean transformedPixels = false;
1167
1237
 
1168
1238
  ExifInterface exifInterface = new ExifInterface(
1169
- tempFile.getAbsolutePath()
1239
+ new ByteArrayInputStream(originalCaptureBytes)
1170
1240
  );
1171
-
1172
- if (location != null) {
1173
- exifInterface.setGpsInfo(location);
1174
- }
1175
-
1241
+ // Build EXIF JSON from captured bytes (location applied by metadata if provided)
1176
1242
  JSONObject exifData = getExifData(exifInterface);
1177
1243
 
1178
1244
  if (width != null || height != null) {
1179
1245
  Bitmap bitmap = BitmapFactory.decodeByteArray(
1180
- bytes,
1246
+ originalCaptureBytes,
1181
1247
  0,
1182
- bytes.length
1248
+ originalCaptureBytes.length
1183
1249
  );
1250
+ bitmap = applyExifOrientation(bitmap, exifInterface);
1184
1251
  Bitmap resizedBitmap = resizeBitmapToMaxDimensions(
1185
1252
  bitmap,
1186
1253
  width,
@@ -1193,15 +1260,31 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1193
1260
  stream
1194
1261
  );
1195
1262
  bytes = stream.toByteArray();
1263
+ transformedPixels = true;
1196
1264
 
1197
- // Write EXIF data back to resized image
1198
- bytes = writeExifToImageBytes(bytes, exifInterface);
1265
+ // Update EXIF JSON to reflect new dimensions; no in-place EXIF write to bytes
1266
+ try {
1267
+ exifData.put("PixelXDimension", resizedBitmap.getWidth());
1268
+ exifData.put("PixelYDimension", resizedBitmap.getHeight());
1269
+ exifData.put("ImageWidth", resizedBitmap.getWidth());
1270
+ exifData.put("ImageLength", resizedBitmap.getHeight());
1271
+ exifData.put(
1272
+ "Orientation",
1273
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
1274
+ );
1275
+ } catch (Exception ignore) {}
1276
+ finalWidthOut = resizedBitmap.getWidth();
1277
+ finalHeightOut = resizedBitmap.getHeight();
1199
1278
  } else {
1200
1279
  // No explicit size/ratio: crop to match current preview content
1201
1280
  Bitmap originalBitmap = BitmapFactory.decodeByteArray(
1202
- bytes,
1281
+ originalCaptureBytes,
1203
1282
  0,
1204
- bytes.length
1283
+ originalCaptureBytes.length
1284
+ );
1285
+ originalBitmap = applyExifOrientation(
1286
+ originalBitmap,
1287
+ exifInterface
1205
1288
  );
1206
1289
  Bitmap previewCropped = cropBitmapToMatchPreview(originalBitmap);
1207
1290
  ByteArrayOutputStream stream = new ByteArrayOutputStream();
@@ -1211,16 +1294,44 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1211
1294
  stream
1212
1295
  );
1213
1296
  bytes = stream.toByteArray();
1214
- // Preserve EXIF
1215
- bytes = writeExifToImageBytes(bytes, exifInterface);
1297
+ transformedPixels = true;
1298
+ // Update EXIF JSON to reflect cropped dimensions; no in-place EXIF write to bytes
1299
+ try {
1300
+ exifData.put("PixelXDimension", previewCropped.getWidth());
1301
+ exifData.put("PixelYDimension", previewCropped.getHeight());
1302
+ exifData.put("ImageWidth", previewCropped.getWidth());
1303
+ exifData.put("ImageLength", previewCropped.getHeight());
1304
+ exifData.put(
1305
+ "Orientation",
1306
+ Integer.toString(ExifInterface.ORIENTATION_NORMAL)
1307
+ );
1308
+ } catch (Exception ignore) {}
1309
+ finalWidthOut = previewCropped.getWidth();
1310
+ finalHeightOut = previewCropped.getHeight();
1311
+ }
1312
+
1313
+ // After any transform, inject EXIF back into the in-memory JPEG bytes (no temp file)
1314
+ if (transformedPixels) {
1315
+ Integer fW = (finalWidthOut > 0) ? finalWidthOut : null;
1316
+ Integer fH = (finalHeightOut > 0) ? finalHeightOut : null;
1317
+ bytes = injectExifInMemory(
1318
+ bytes,
1319
+ originalCaptureBytes,
1320
+ fW,
1321
+ fH,
1322
+ /*normalizeOrientation*/true
1323
+ );
1216
1324
  }
1217
1325
 
1218
- // Save to gallery asynchronously if requested
1326
+ // Save to gallery asynchronously if requested, copy EXIF to file
1219
1327
  if (saveToGallery) {
1220
1328
  final byte[] finalBytes = bytes;
1221
- new Thread(() -> {
1222
- saveImageToGallery(finalBytes);
1223
- }).start();
1329
+ final ExifInterface exifForFile = exifInterface;
1330
+ final Integer fW = (finalWidthOut > 0) ? finalWidthOut : null;
1331
+ final Integer fH = (finalHeightOut > 0) ? finalHeightOut : null;
1332
+ new Thread(() ->
1333
+ saveImageToGallery(finalBytes, exifForFile, fW, fH)
1334
+ ).start();
1224
1335
  }
1225
1336
 
1226
1337
  String resultValue;
@@ -1241,6 +1352,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1241
1352
  outFos.write(bytes);
1242
1353
  outFos.close();
1243
1354
 
1355
+ // No EXIF rewrite here; bytes already contain EXIF when needed
1356
+
1244
1357
  // Return a file path; apps can convert via Capacitor.convertFileSrc on JS side
1245
1358
  resultValue = outFile.getAbsolutePath();
1246
1359
  } catch (IOException ioEx) {
@@ -1253,8 +1366,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1253
1366
  resultValue = Base64.encodeToString(bytes, Base64.NO_WRAP);
1254
1367
  }
1255
1368
 
1256
- tempFile.delete();
1257
-
1258
1369
  if (listener != null) {
1259
1370
  listener.onPictureTaken(resultValue, exifData);
1260
1371
  }
@@ -1273,14 +1384,56 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1273
1384
  performImmediateStop();
1274
1385
  }
1275
1386
  }
1387
+ endOperation("capturePhoto");
1276
1388
  }
1277
1389
  }
1278
1390
  }
1279
1391
  );
1280
1392
  }
1281
1393
 
1282
- private Bitmap resizeBitmap(Bitmap bitmap, int width, int height) {
1283
- return Bitmap.createScaledBitmap(bitmap, width, height, true);
1394
+ private int exifToDegrees(int exifOrientation) {
1395
+ switch (exifOrientation) {
1396
+ case ExifInterface.ORIENTATION_ROTATE_90:
1397
+ case ExifInterface.ORIENTATION_TRANSPOSE:
1398
+ return 90;
1399
+ case ExifInterface.ORIENTATION_ROTATE_180:
1400
+ return 180;
1401
+ case ExifInterface.ORIENTATION_ROTATE_270:
1402
+ case ExifInterface.ORIENTATION_TRANSVERSE:
1403
+ return 270;
1404
+ default:
1405
+ return 0;
1406
+ }
1407
+ }
1408
+
1409
+ private Bitmap applyExifOrientation(Bitmap bitmap, ExifInterface exif) {
1410
+ try {
1411
+ int orientation = exif.getAttributeInt(
1412
+ ExifInterface.TAG_ORIENTATION,
1413
+ ExifInterface.ORIENTATION_UNDEFINED
1414
+ );
1415
+ int rotation = exifToDegrees(orientation);
1416
+ if (rotation == 0) return bitmap;
1417
+ Matrix m = new Matrix();
1418
+ m.postRotate(rotation);
1419
+ Bitmap rotated = Bitmap.createBitmap(
1420
+ bitmap,
1421
+ 0,
1422
+ 0,
1423
+ bitmap.getWidth(),
1424
+ bitmap.getHeight(),
1425
+ m,
1426
+ true
1427
+ );
1428
+ if (rotated != bitmap) {
1429
+ try {
1430
+ bitmap.recycle();
1431
+ } catch (Exception ignore) {}
1432
+ }
1433
+ return rotated;
1434
+ } catch (Exception e) {
1435
+ return bitmap;
1436
+ }
1284
1437
  }
1285
1438
 
1286
1439
  private Bitmap resizeBitmapToMaxDimensions(
@@ -1292,7 +1445,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1292
1445
  int originalHeight = bitmap.getHeight();
1293
1446
  float originalAspectRatio = (float) originalWidth / originalHeight;
1294
1447
 
1295
- int targetWidth = originalWidth;
1448
+ int targetWidth;
1296
1449
  int targetHeight = originalHeight;
1297
1450
 
1298
1451
  if (maxWidth != null && maxHeight != null) {
@@ -1336,6 +1489,69 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1336
1489
  return exifData;
1337
1490
  }
1338
1491
 
1492
+ // Inject EXIF into a JPEG byte[] fully in-memory using Apache Commons Imaging (no temp files)
1493
+ // Copies EXIF from sourceJpeg (original capture) and updates orientation/dimensions if provided.
1494
+ private byte[] injectExifInMemory(
1495
+ byte[] targetJpeg,
1496
+ byte[] sourceJpegWithExif,
1497
+ Integer finalWidth,
1498
+ Integer finalHeight,
1499
+ boolean normalizeOrientation
1500
+ ) {
1501
+ try {
1502
+ // Quick signature check for JPEG (FF D8 FF)
1503
+ if (
1504
+ targetJpeg == null ||
1505
+ targetJpeg.length < 3 ||
1506
+ (targetJpeg[0] & 0xFF) != 0xFF ||
1507
+ (targetJpeg[1] & 0xFF) != 0xD8 ||
1508
+ (targetJpeg[2] & 0xFF) != 0xFF
1509
+ ) {
1510
+ return targetJpeg; // Not a JPEG; nothing to do
1511
+ }
1512
+
1513
+ // Use Commons Imaging to read EXIF from the original capture bytes
1514
+ org.apache.commons.imaging.formats.jpeg.JpegImageMetadata jpegMetadata =
1515
+ (org.apache.commons.imaging.formats.jpeg.JpegImageMetadata) org.apache.commons.imaging.Imaging.getMetadata(
1516
+ sourceJpegWithExif
1517
+ );
1518
+ org.apache.commons.imaging.formats.tiff.TiffImageMetadata exif =
1519
+ jpegMetadata != null ? jpegMetadata.getExif() : null;
1520
+
1521
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet =
1522
+ exif != null
1523
+ ? exif.getOutputSet()
1524
+ : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
1525
+
1526
+ // Update orientation if requested (normalize to 1)
1527
+ if (normalizeOrientation) {
1528
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory rootDir =
1529
+ outputSet.getOrCreateRootDirectory();
1530
+ rootDir.removeField(
1531
+ org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants.TIFF_TAG_ORIENTATION
1532
+ );
1533
+ rootDir.add(
1534
+ org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants.TIFF_TAG_ORIENTATION,
1535
+ (short) 1
1536
+ );
1537
+ }
1538
+
1539
+ // Optionally update dimensions here. Skipped to maximize compatibility with Commons Imaging 1.0-alpha3.
1540
+
1541
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1542
+ new org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter()
1543
+ .updateExifMetadataLossless(
1544
+ new java.io.ByteArrayInputStream(targetJpeg),
1545
+ out,
1546
+ outputSet
1547
+ );
1548
+ return out.toByteArray();
1549
+ } catch (Throwable t) {
1550
+ Log.w(TAG, "injectExifInMemory: Failed to write EXIF in memory", t);
1551
+ return targetJpeg; // Fallback: return original bytes
1552
+ }
1553
+ }
1554
+
1339
1555
  private static final String[][] EXIF_TAGS = new String[][] {
1340
1556
  { ExifInterface.TAG_APERTURE_VALUE, "ApertureValue" },
1341
1557
  { ExifInterface.TAG_ARTIST, "Artist" },
@@ -1501,54 +1717,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1501
1717
  { ExifInterface.TAG_Y_RESOLUTION, "YResolution" },
1502
1718
  };
1503
1719
 
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
- }
1720
+ // Note: We avoid temporary files for EXIF writes. When we transform pixels (resize/crop),
1721
+ // we recompress JPEG in-memory and update EXIF info only in the returned JSON, not in the bytes.
1550
1722
 
1551
1723
  public void captureSample(int quality) {
1724
+ if (IsOperationRunning("captureSample")) {
1725
+ Log.d(TAG, "captureSample: Ignored because stop is pending");
1726
+ return;
1727
+ }
1552
1728
  Log.d(
1553
1729
  TAG,
1554
1730
  "captureSample: Starting sample capture with quality: " + quality
@@ -1572,10 +1748,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1572
1748
  "Sample capture failed: " + exception.getMessage()
1573
1749
  );
1574
1750
  }
1751
+ endOperation("captureSample");
1575
1752
  }
1576
1753
 
1577
1754
  @Override
1578
1755
  public void onCaptureSuccess(@NonNull ImageProxy image) {
1756
+ //noinspection TryFinallyCanBeTryWithResources
1579
1757
  try {
1580
1758
  // Convert ImageProxy to byte array
1581
1759
  byte[] bytes = imageProxyToByteArray(image);
@@ -1593,6 +1771,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1593
1771
  }
1594
1772
  } finally {
1595
1773
  image.close();
1774
+ endOperation("captureSample");
1596
1775
  }
1597
1776
  }
1598
1777
  }
@@ -1653,13 +1832,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1653
1832
  // not workin for xiaomi https://xiaomi.eu/community/threads/mi-11-ultra-unable-to-access-camera-lenses-in-apps-camera2-api.61456/
1654
1833
  @OptIn(markerClass = ExperimentalCamera2Interop.class)
1655
1834
  public static List<
1656
- com.ahm.capacitor.camera.preview.model.CameraDevice
1835
+ app.capgo.capacitor.camera.preview.model.CameraDevice
1657
1836
  > getAvailableDevicesStatic(Context context) {
1658
1837
  Log.d(
1659
1838
  TAG,
1660
1839
  "getAvailableDevicesStatic: Starting CameraX device enumeration with getPhysicalCameraInfos."
1661
1840
  );
1662
- List<com.ahm.capacitor.camera.preview.model.CameraDevice> devices =
1841
+ List<app.capgo.capacitor.camera.preview.model.CameraDevice> devices =
1663
1842
  new ArrayList<>();
1664
1843
  try {
1665
1844
  ListenableFuture<ProcessCameraProvider> cameraProviderFuture =
@@ -1683,7 +1862,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1683
1862
  List<LensInfo> logicalLenses = new ArrayList<>();
1684
1863
  logicalLenses.add(new LensInfo(4.25f, "wideAngle", 1.0f, maxZoom));
1685
1864
  devices.add(
1686
- new com.ahm.capacitor.camera.preview.model.CameraDevice(
1865
+ new app.capgo.capacitor.camera.preview.model.CameraDevice(
1687
1866
  logicalCameraId,
1688
1867
  "Logical Camera (" + position + ")",
1689
1868
  position,
@@ -1782,7 +1961,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1782
1961
  );
1783
1962
 
1784
1963
  devices.add(
1785
- new com.ahm.capacitor.camera.preview.model.CameraDevice(
1964
+ new app.capgo.capacitor.camera.preview.model.CameraDevice(
1786
1965
  physicalId,
1787
1966
  label,
1788
1967
  position,
@@ -1930,6 +2109,29 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1930
2109
  }
1931
2110
 
1932
2111
  public void setFocus(float x, float y) throws Exception {
2112
+ // Ignore focus if capture/stop is in progress or view is gone
2113
+ synchronized (captureLock) {
2114
+ if (isCapturingPhoto || stopRequested) {
2115
+ Log.d(TAG, "setFocus: Ignored because capture/stop in progress");
2116
+ return;
2117
+ }
2118
+ }
2119
+ if (
2120
+ !isRunning ||
2121
+ camera == null ||
2122
+ previewView == null ||
2123
+ previewContainer == null
2124
+ ) {
2125
+ Log.d(
2126
+ TAG,
2127
+ "setFocus: Ignored because camera/view not ready or not running"
2128
+ );
2129
+ return;
2130
+ }
2131
+ if (IsOperationRunning("setFocus")) {
2132
+ Log.d(TAG, "setFocus: Ignored because stop is pending");
2133
+ return;
2134
+ }
1933
2135
  if (camera == null) {
1934
2136
  throw new Exception("Camera not initialized");
1935
2137
  }
@@ -1955,7 +2157,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1955
2157
  ExposureState state = camera.getCameraInfo().getExposureState();
1956
2158
  Range<Integer> range = state.getExposureCompensationRange();
1957
2159
  int zeroIdx = 0;
1958
- if (range != null && !range.contains(0)) {
2160
+ if (!range.contains(0)) {
1959
2161
  // Choose the closest index to 0 if 0 is not available
1960
2162
  zeroIdx = Math.abs(range.getLower()) < Math.abs(range.getUpper())
1961
2163
  ? range.getLower()
@@ -1978,9 +2180,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1978
2180
  // Only show focus indicator after validation passes
1979
2181
  float indicatorX = x * viewWidth;
1980
2182
  float indicatorY = y * viewHeight;
1981
- showFocusIndicator(indicatorX, indicatorY);
2183
+ final long indicatorToken;
2184
+ long indicatorToken1;
2185
+ try {
2186
+ indicatorToken1 = showFocusIndicator(indicatorX, indicatorY);
2187
+ } catch (Exception ignore) {
2188
+ // If we can't show the indicator (e.g., view is gone), still proceed with metering
2189
+ // Use current token so hide is a no-op later
2190
+ indicatorToken1 = focusIndicatorAnimationId;
2191
+ }
1982
2192
 
1983
2193
  // Create MeteringPoint using the preview view
2194
+ indicatorToken = indicatorToken1;
1984
2195
  MeteringPointFactory factory = previewView.getMeteringPointFactory();
1985
2196
  MeteringPoint point = factory.createPoint(x * viewWidth, y * viewHeight);
1986
2197
 
@@ -2026,6 +2237,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2026
2237
  if (currentFocusFuture == future && currentFocusFuture.isDone()) {
2027
2238
  currentFocusFuture = null;
2028
2239
  }
2240
+ hideFocusIndicator(indicatorToken);
2241
+ endOperation("setFocus");
2029
2242
  }
2030
2243
  },
2031
2244
  ContextCompat.getMainExecutor(context)
@@ -2033,6 +2246,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2033
2246
  } catch (Exception e) {
2034
2247
  currentFocusFuture = null;
2035
2248
  Log.e(TAG, "Failed to set focus: " + e.getMessage());
2249
+ hideFocusIndicator(indicatorToken);
2250
+ endOperation("setFocus");
2036
2251
  throw e;
2037
2252
  }
2038
2253
  }
@@ -2056,40 +2271,36 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2056
2271
  }
2057
2272
  String normalized = mode.toUpperCase(Locale.US);
2058
2273
 
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);
2274
+ Camera2CameraControl c2 = Camera2CameraControl.from(
2275
+ camera.getCameraControl()
2276
+ );
2277
+ switch (normalized) {
2278
+ case "LOCK": {
2279
+ CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2280
+ .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, true)
2281
+ .setCaptureRequestOption(
2282
+ CaptureRequest.CONTROL_AE_MODE,
2283
+ CaptureRequest.CONTROL_AE_MODE_ON
2284
+ )
2285
+ .build();
2286
+ mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2287
+ currentExposureMode = "LOCK";
2288
+ break;
2090
2289
  }
2091
- } catch (Exception e) {
2092
- throw e;
2290
+ case "CONTINUOUS": {
2291
+ CaptureRequestOptions opts = new CaptureRequestOptions.Builder()
2292
+ .setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, false)
2293
+ .setCaptureRequestOption(
2294
+ CaptureRequest.CONTROL_AE_MODE,
2295
+ CaptureRequest.CONTROL_AE_MODE_ON
2296
+ )
2297
+ .build();
2298
+ mainExecutor.execute(() -> c2.setCaptureRequestOptions(opts));
2299
+ currentExposureMode = "CONTINUOUS";
2300
+ break;
2301
+ }
2302
+ default:
2303
+ throw new Exception("Unsupported exposure mode: " + mode);
2093
2304
  }
2094
2305
  }
2095
2306
 
@@ -2100,9 +2311,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2100
2311
  ExposureState state = camera.getCameraInfo().getExposureState();
2101
2312
  Range<Integer> idxRange = state.getExposureCompensationRange();
2102
2313
  Rational step = state.getExposureCompensationStep();
2103
- float evStep = step != null
2104
- ? (float) step.getNumerator() / (float) step.getDenominator()
2105
- : 1.0f;
2314
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2106
2315
  float min = idxRange.getLower() * evStep;
2107
2316
  float max = idxRange.getUpper() * evStep;
2108
2317
  return new float[] { min, max, evStep };
@@ -2115,9 +2324,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2115
2324
  ExposureState state = camera.getCameraInfo().getExposureState();
2116
2325
  int idx = state.getExposureCompensationIndex();
2117
2326
  Rational step = state.getExposureCompensationStep();
2118
- float evStep = step != null
2119
- ? (float) step.getNumerator() / (float) step.getDenominator()
2120
- : 1.0f;
2327
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2121
2328
  return idx * evStep;
2122
2329
  }
2123
2330
 
@@ -2128,9 +2335,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2128
2335
  ExposureState state = camera.getCameraInfo().getExposureState();
2129
2336
  Range<Integer> idxRange = state.getExposureCompensationRange();
2130
2337
  Rational step = state.getExposureCompensationStep();
2131
- float evStep = step != null
2132
- ? (float) step.getNumerator() / (float) step.getDenominator()
2133
- : 1.0f;
2338
+ float evStep = (float) step.getNumerator() / (float) step.getDenominator();
2134
2339
  if (evStep <= 0f) evStep = 1.0f;
2135
2340
  int idx = Math.round(ev / evStep);
2136
2341
  // clamp
@@ -2139,13 +2344,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2139
2344
  camera.getCameraControl().setExposureCompensationIndex(idx);
2140
2345
  }
2141
2346
 
2142
- private void showFocusIndicator(float x, float y) {
2143
- if (disableFocusIndicator || sessionConfig.getDisableFocusIndicator()) {
2144
- return;
2145
- }
2347
+ private long showFocusIndicator(float x, float y) {
2348
+ // If preview is gone (e.g., stopping/closing), bail out safely
2146
2349
  if (previewContainer == null) {
2147
2350
  Log.w(TAG, "showFocusIndicator: previewContainer is null");
2148
- return;
2351
+ return focusIndicatorAnimationId;
2352
+ }
2353
+ if (sessionConfig.getDisableFocusIndicator()) {
2354
+ return focusIndicatorAnimationId;
2149
2355
  }
2150
2356
 
2151
2357
  // Check if container has been laid out
@@ -2155,17 +2361,24 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2155
2361
  "showFocusIndicator: previewContainer not laid out yet, posting to run after layout"
2156
2362
  );
2157
2363
  previewContainer.post(() -> showFocusIndicator(x, y));
2158
- return;
2364
+ return focusIndicatorAnimationId;
2159
2365
  }
2160
2366
 
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
- }
2367
+ // Remove any existing focus indicators (ensure only one is visible)
2368
+ try {
2369
+ for (int i = previewContainer.getChildCount() - 1; i >= 0; i--) {
2370
+ View child = previewContainer.getChildAt(i);
2371
+ CharSequence desc = child.getContentDescription();
2372
+ if (desc != null && FOCUS_INDICATOR_TAG.contentEquals(desc)) {
2373
+ previewContainer.removeViewAt(i);
2374
+ }
2375
+ }
2376
+ if (focusIndicatorView != null) {
2377
+ ViewGroup parent = (ViewGroup) focusIndicatorView.getParent();
2378
+ if (parent != null) parent.removeView(focusIndicatorView);
2379
+ focusIndicatorView = null;
2380
+ }
2381
+ } catch (Exception ignore) {}
2169
2382
 
2170
2383
  // Create an elegant focus indicator
2171
2384
  FrameLayout container = new FrameLayout(context);
@@ -2178,11 +2391,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2178
2391
 
2179
2392
  params.leftMargin = Math.max(
2180
2393
  0,
2181
- Math.min((int) (x - size / 2), containerWidth - size)
2394
+ Math.min((int) (x - (float) size / 2), containerWidth - size)
2182
2395
  );
2183
2396
  params.topMargin = Math.max(
2184
2397
  0,
2185
- Math.min((int) (y - size / 2), containerHeight - size)
2398
+ Math.min((int) (y - (float) size / 2), containerHeight - size)
2186
2399
  );
2187
2400
 
2188
2401
  // iOS Camera style: square with mid-edge ticks
@@ -2197,7 +2410,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2197
2410
  // Add 4 tiny mid-edge ticks inside the square
2198
2411
  int tickLen = (int) (12 *
2199
2412
  context.getResources().getDisplayMetrics().density);
2200
- int inset = stroke; // ticks should touch the sides
2413
+ // ticks should touch the sides
2201
2414
  // Top tick (perpendicular): vertical inward from top edge
2202
2415
  View topTick = new View(context);
2203
2416
  FrameLayout.LayoutParams topParams = new FrameLayout.LayoutParams(
@@ -2205,7 +2418,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2205
2418
  tickLen
2206
2419
  );
2207
2420
  topParams.leftMargin = (size - stroke) / 2;
2208
- topParams.topMargin = inset;
2421
+ topParams.topMargin = stroke;
2209
2422
  topTick.setLayoutParams(topParams);
2210
2423
  topTick.setBackgroundColor(Color.YELLOW);
2211
2424
  container.addView(topTick);
@@ -2216,7 +2429,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2216
2429
  tickLen
2217
2430
  );
2218
2431
  bottomParams.leftMargin = (size - stroke) / 2;
2219
- bottomParams.topMargin = size - inset - tickLen;
2432
+ bottomParams.topMargin = size - stroke - tickLen;
2220
2433
  bottomTick.setLayoutParams(bottomParams);
2221
2434
  bottomTick.setBackgroundColor(Color.YELLOW);
2222
2435
  container.addView(bottomTick);
@@ -2226,7 +2439,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2226
2439
  tickLen,
2227
2440
  stroke
2228
2441
  );
2229
- leftParams.leftMargin = inset;
2442
+ leftParams.leftMargin = stroke;
2230
2443
  leftParams.topMargin = (size - stroke) / 2;
2231
2444
  leftTick.setLayoutParams(leftParams);
2232
2445
  leftTick.setBackgroundColor(Color.YELLOW);
@@ -2237,21 +2450,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2237
2450
  tickLen,
2238
2451
  stroke
2239
2452
  );
2240
- rightParams.leftMargin = size - inset - tickLen;
2453
+ rightParams.leftMargin = size - stroke - tickLen;
2241
2454
  rightParams.topMargin = (size - stroke) / 2;
2242
2455
  rightTick.setLayoutParams(rightParams);
2243
2456
  rightTick.setBackgroundColor(Color.YELLOW);
2244
2457
  container.addView(rightTick);
2245
2458
 
2459
+ container.setContentDescription(FOCUS_INDICATOR_TAG);
2246
2460
  focusIndicatorView = container;
2247
2461
  // Bump animation token; everything after this must validate against this token
2248
2462
  final long thisAnimationId = ++focusIndicatorAnimationId;
2249
2463
  final View thisIndicatorView = focusIndicatorView;
2250
2464
 
2251
- // Set initial state for smooth animation (mirror iOS)
2252
- focusIndicatorView.setAlpha(0f);
2253
- focusIndicatorView.setScaleX(1.5f);
2254
- focusIndicatorView.setScaleY(1.5f);
2465
+ // Show immediately (avoid complex animations that can race with teardown)
2466
+ focusIndicatorView.setAlpha(1f);
2467
+ focusIndicatorView.setScaleX(1f);
2468
+ focusIndicatorView.setScaleY(1f);
2255
2469
  focusIndicatorView.setVisibility(View.VISIBLE);
2256
2470
 
2257
2471
  // Ensure container doesn't intercept touch events
@@ -2259,12 +2473,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2259
2473
  container.setFocusable(false);
2260
2474
 
2261
2475
  // 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
- }
2476
+ focusIndicatorView.setElevation(10f);
2268
2477
 
2269
2478
  // Add to container first
2270
2479
  previewContainer.addView(focusIndicatorView, params);
@@ -2275,88 +2484,43 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2275
2484
  // Force a layout pass to ensure the view is properly positioned
2276
2485
  previewContainer.requestLayout();
2277
2486
 
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
2487
+ // Do not schedule delayed cleanup; indicator will be removed when focus completes
2488
+ return thisAnimationId;
2489
+ }
2490
+
2491
+ private void hideFocusIndicator(long token) {
2492
+ // If we're stopping or not running anymore, don't attempt to touch the view tree
2493
+ if (stopRequested || !isRunning) {
2494
+ focusIndicatorView = null;
2495
+ return;
2496
+ }
2497
+ try {
2498
+ mainExecutor.execute(() -> {
2499
+ try {
2293
2500
  if (
2294
- focusIndicatorView != null &&
2295
- thisIndicatorView == focusIndicatorView &&
2296
- thisAnimationId == focusIndicatorAnimationId
2501
+ focusIndicatorView == null ||
2502
+ previewContainer == null ||
2503
+ token != focusIndicatorAnimationId
2297
2504
  ) {
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
- );
2505
+ return;
2506
+ }
2507
+ // If the view hierarchy is already being torn down, skip safely
2508
+ if (!previewContainer.isAttachedToWindow()) {
2509
+ focusIndicatorView = null;
2510
+ return;
2355
2511
  }
2512
+ ViewGroup parent = (ViewGroup) focusIndicatorView.getParent();
2513
+ if (parent != null) {
2514
+ parent.removeView(focusIndicatorView);
2515
+ }
2516
+ } catch (Exception ignore) {} finally {
2517
+ focusIndicatorView = null;
2356
2518
  }
2357
- },
2358
- 500
2359
- );
2519
+ });
2520
+ } catch (Exception ignore) {
2521
+ // Executor or Looper not available; just null out the reference
2522
+ focusIndicatorView = null;
2523
+ }
2360
2524
  }
2361
2525
 
2362
2526
  public static List<Size> getSupportedPictureSizes(String facing) {
@@ -2562,7 +2726,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2562
2726
  sessionConfig.isToBack(), // toBack
2563
2727
  sessionConfig.isStoreToFile(), // storeToFile
2564
2728
  sessionConfig.isEnableOpacity(), // enableOpacity
2565
- sessionConfig.isEnableZoom(), // enableZoom
2566
2729
  sessionConfig.isDisableExifHeaderStripping(), // disableExifHeaderStripping
2567
2730
  sessionConfig.isDisableAudio(), // disableAudio
2568
2731
  sessionConfig.getZoomFactor(), // zoomFactor
@@ -2722,7 +2885,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2722
2885
  sessionConfig.getToBack(),
2723
2886
  sessionConfig.getStoreToFile(),
2724
2887
  sessionConfig.getEnableOpacity(),
2725
- sessionConfig.getEnableZoom(),
2726
2888
  sessionConfig.getDisableExifHeaderStripping(),
2727
2889
  sessionConfig.getDisableAudio(),
2728
2890
  sessionConfig.getZoomFactor(),
@@ -2737,7 +2899,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2737
2899
  if (isRunning && previewContainer != null) {
2738
2900
  mainExecutor.execute(() -> {
2739
2901
  // First update the UI layout - always pass null for x,y to force auto-centering (matching iOS)
2740
- updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
2902
+ updatePreviewLayoutForAspectRatio(aspectRatio);
2741
2903
 
2742
2904
  // Then rebind the camera with new aspect ratio configuration
2743
2905
  Log.d(
@@ -2826,7 +2988,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2826
2988
  sessionConfig.getToBack(),
2827
2989
  sessionConfig.getStoreToFile(),
2828
2990
  sessionConfig.getEnableOpacity(),
2829
- sessionConfig.getEnableZoom(),
2830
2991
  sessionConfig.getDisableExifHeaderStripping(),
2831
2992
  sessionConfig.getDisableAudio(),
2832
2993
  sessionConfig.getZoomFactor(),
@@ -2841,7 +3002,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2841
3002
  if (isRunning && previewContainer != null) {
2842
3003
  mainExecutor.execute(() -> {
2843
3004
  // First update the UI layout - always pass null for x,y to force auto-centering (matching iOS)
2844
- updatePreviewLayoutForAspectRatio(aspectRatio, null, null);
3005
+ updatePreviewLayoutForAspectRatio(aspectRatio);
2845
3006
 
2846
3007
  // Then rebind the camera with new aspect ratio configuration
2847
3008
  Log.d(
@@ -2902,7 +3063,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2902
3063
  sessionConfig.getToBack(),
2903
3064
  sessionConfig.getStoreToFile(),
2904
3065
  sessionConfig.getEnableOpacity(),
2905
- sessionConfig.getEnableZoom(),
2906
3066
  sessionConfig.getDisableExifHeaderStripping(),
2907
3067
  sessionConfig.getDisableAudio(),
2908
3068
  sessionConfig.getZoomFactor(),
@@ -3010,6 +3170,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3010
3170
  // Swap dimensions if in portrait mode to match how PreviewView displays it
3011
3171
  if (isPortrait) {
3012
3172
  int temp = cameraWidth;
3173
+ //noinspection SuspiciousNameCombination,ReassignedVariable
3013
3174
  cameraWidth = cameraHeight;
3014
3175
  cameraHeight = temp;
3015
3176
  }
@@ -3286,7 +3447,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3286
3447
  sessionConfig.getToBack(),
3287
3448
  sessionConfig.getStoreToFile(),
3288
3449
  sessionConfig.getEnableOpacity(),
3289
- sessionConfig.getEnableZoom(),
3290
3450
  sessionConfig.getDisableExifHeaderStripping(),
3291
3451
  sessionConfig.getDisableAudio(),
3292
3452
  sessionConfig.getZoomFactor(),
@@ -3318,7 +3478,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3318
3478
  previewContainer.post(callback);
3319
3479
  });
3320
3480
  } else {
3321
- previewContainer.post(() -> updateGridOverlayBounds());
3481
+ previewContainer.post(this::updateGridOverlayBounds);
3322
3482
  }
3323
3483
  } else {
3324
3484
  // No camera rebinding needed, wait for layout to complete then call callback
@@ -3359,29 +3519,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3359
3519
  }
3360
3520
 
3361
3521
  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
3522
  if (previewContainer == null || aspectRatio == null) return;
3371
3523
 
3372
3524
  Log.d(
3373
3525
  TAG,
3374
3526
  "======================== UPDATE PREVIEW LAYOUT FOR ASPECT RATIO ========================"
3375
3527
  );
3376
- Log.d(
3377
- TAG,
3378
- "Input parameters - aspectRatio: " +
3379
- aspectRatio +
3380
- ", x: " +
3381
- x +
3382
- ", y: " +
3383
- y
3384
- );
3528
+ Log.d(TAG, "Input parameters - aspectRatio: " + aspectRatio);
3385
3529
 
3386
3530
  // Get comprehensive display information
3387
3531
  WindowManager windowManager = (WindowManager) this.context.getSystemService(
@@ -3491,115 +3635,57 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3491
3635
  );
3492
3636
 
3493
3637
  // Get available space from webview dimensions
3494
- int availableWidth = webViewWidth;
3495
- int availableHeight = webViewHeight;
3496
3638
 
3497
3639
  Log.d(
3498
3640
  TAG,
3499
- "Available space from WebView: " +
3500
- availableWidth +
3501
- "x" +
3502
- availableHeight
3641
+ "Available space from WebView: " + webViewWidth + "x" + webViewHeight
3503
3642
  );
3504
3643
 
3505
3644
  // Calculate position and size
3506
3645
  int finalX, finalY, finalWidth, finalHeight;
3646
+ // Auto-center mode - match iOS behavior exactly
3647
+ Log.d(TAG, "Auto-center mode");
3507
3648
 
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;
3533
-
3534
- Log.d(
3535
- TAG,
3536
- "Max available space from position: " + maxWidth + "x" + maxHeight
3537
- );
3649
+ // Calculate maximum size that fits the aspect ratio in available space
3650
+ double maxWidthByHeight = webViewHeight * ratio;
3651
+ double maxHeightByWidth = webViewWidth / ratio;
3538
3652
 
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
- }
3653
+ Log.d(
3654
+ TAG,
3655
+ "Aspect ratio calculations - maxWidthByHeight: " +
3656
+ maxWidthByHeight +
3657
+ ", maxHeightByWidth: " +
3658
+ maxHeightByWidth
3659
+ );
3551
3660
 
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));
3661
+ if (maxWidthByHeight <= webViewWidth) {
3662
+ // Height is the limiting factor
3663
+ finalWidth = (int) maxWidthByHeight;
3664
+ finalHeight = webViewHeight;
3665
+ Log.d(TAG, "Height-limited sizing: " + finalWidth + "x" + finalHeight);
3555
3666
  } 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
- }
3667
+ // Width is the limiting factor
3668
+ finalWidth = webViewWidth;
3669
+ finalHeight = (int) maxHeightByWidth;
3670
+ Log.d(TAG, "Width-limited sizing: " + finalWidth + "x" + finalHeight);
3671
+ }
3585
3672
 
3586
- // Center the preview
3587
- finalX = (availableWidth - finalWidth) / 2;
3588
- finalY = (availableHeight - finalHeight) / 2;
3673
+ // Center the preview
3674
+ finalX = (webViewWidth - finalWidth) / 2;
3675
+ finalY = (webViewHeight - finalHeight) / 2;
3589
3676
 
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
- }
3677
+ Log.d(
3678
+ TAG,
3679
+ "Auto-center mode: calculated size " +
3680
+ finalWidth +
3681
+ "x" +
3682
+ finalHeight +
3683
+ " at position (" +
3684
+ finalX +
3685
+ ", " +
3686
+ finalY +
3687
+ ")"
3688
+ );
3603
3689
 
3604
3690
  Log.d(
3605
3691
  TAG,
@@ -3627,24 +3713,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3627
3713
  );
3628
3714
 
3629
3715
  // 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
- }
3716
+ String[] parts = aspectRatio.split(":");
3717
+ if (parts.length == 2) {
3718
+ double expectedDisplayRatio = isPortrait
3719
+ ? (ratioHeight / ratioWidth)
3720
+ : (ratioWidth / ratioHeight);
3721
+ double difference = Math.abs(displayedRatio - expectedDisplayRatio);
3722
+ Log.d(
3723
+ TAG,
3724
+ "Display ratio check - Expected: " +
3725
+ expectedDisplayRatio +
3726
+ ", Actual: " +
3727
+ displayedRatio +
3728
+ ", Difference: " +
3729
+ difference +
3730
+ " (tolerance should be < 0.01)"
3731
+ );
3648
3732
  }
3649
3733
 
3650
3734
  // Update layout params
@@ -3796,93 +3880,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3796
3880
 
3797
3881
  Log.d(
3798
3882
  TAG,
3799
- "updateGridOverlayBounds: Updated grid bounds to " +
3800
- cameraBounds.toString()
3801
- );
3802
- }
3803
- }
3804
-
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)
3883
+ "updateGridOverlayBounds: Updated grid bounds to " + cameraBounds
3879
3884
  );
3880
- } catch (Exception e) {
3881
- currentFocusFuture = null;
3882
- Log.e(TAG, "triggerAutoFocus: Failed to trigger autofocus", e);
3883
3885
  }
3884
3886
  }
3885
3887
 
3888
+ /** @noinspection ResultOfMethodCallIgnored*/
3886
3889
  public void startRecordVideo() throws Exception {
3887
3890
  if (videoCapture == null) {
3888
3891
  throw new Exception("VideoCapture is not initialized");
@@ -3921,6 +3924,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3921
3924
 
3922
3925
  // Start recording
3923
3926
  if (sessionConfig != null && !sessionConfig.isDisableAudio()) {
3927
+ if (
3928
+ ActivityCompat.checkSelfPermission(
3929
+ context,
3930
+ Manifest.permission.RECORD_AUDIO
3931
+ ) !=
3932
+ PackageManager.PERMISSION_GRANTED
3933
+ ) {
3934
+ return;
3935
+ }
3924
3936
  currentRecording = videoCapture
3925
3937
  .getOutput()
3926
3938
  .prepareRecording(context, outputOptions)
@@ -3945,60 +3957,6 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3945
3957
  );
3946
3958
  }
3947
3959
 
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
3960
  public void stopRecordVideo(VideoRecordingCallback callback) {
4003
3961
  if (currentRecording == null) {
4004
3962
  callback.onError("No video recording in progress");