@capgo/camera-preview 8.11.9 → 8.11.11

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.
@@ -66,6 +66,8 @@ import androidx.camera.core.MirrorMode;
66
66
  import androidx.camera.core.Preview;
67
67
  import androidx.camera.core.ResolutionInfo;
68
68
  import androidx.camera.core.TorchState;
69
+ import androidx.camera.core.UseCaseGroup;
70
+ import androidx.camera.core.ViewPort;
69
71
  import androidx.camera.core.ZoomState;
70
72
  import androidx.camera.core.resolutionselector.AspectRatioStrategy;
71
73
  import androidx.camera.core.resolutionselector.ResolutionSelector;
@@ -125,6 +127,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
125
127
 
126
128
  private static final String TAG = "CameraPreview CameraXView";
127
129
  private static final String FOCUS_INDICATOR_TAG = "cpcp_focus_indicator";
130
+ private static final Size DEFAULT_MAX_CAPTURE_RESOLUTION = new Size(1920, 1080);
128
131
 
129
132
  public interface CameraXViewListener {
130
133
  void onPictureTaken(String base64, JSONObject exif);
@@ -169,6 +172,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
169
172
  private String currentPhysicalDeviceId;
170
173
  private String currentLogicalDeviceId;
171
174
  private int currentFlashMode = ImageCapture.FLASH_MODE_OFF;
175
+ private boolean torchRequested = false;
172
176
  private CameraSessionConfiguration sessionConfig;
173
177
  private CameraXViewListener listener;
174
178
  private final Context context;
@@ -186,6 +190,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
186
190
  private static volatile boolean enumeratedDeviceCacheRefreshInProgress = false;
187
191
  private boolean isRunning = false;
188
192
  private Size currentPreviewResolution = null;
193
+ private volatile boolean viewportCropEnabled = false;
194
+ private volatile boolean pendingViewportRebind = false;
195
+ private volatile Size viewportBoundSize = null;
196
+ private View.OnLayoutChangeListener viewportRebindListener = null;
189
197
  private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
190
198
  private Integer configuredVideoFrameRate = null;
191
199
  private Range<Integer> configuredVideoFrameRateRange = null;
@@ -196,6 +204,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
196
204
  // Capture/stop coordination
197
205
  private final Object captureLock = new Object();
198
206
  private volatile boolean isCapturingPhoto = false;
207
+ private volatile boolean viewportRebindInFlight = false;
199
208
  private volatile boolean stopRequested = false;
200
209
  private volatile boolean previewDetachedOnDeferredStop = false;
201
210
  private volatile boolean isBarcodeScannerActive = false;
@@ -619,6 +628,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
619
628
 
620
629
  private void performImmediateStop() {
621
630
  isRunning = false;
631
+ rejectPendingFrameRateBindOnStop();
632
+ viewportCropEnabled = false;
633
+ pendingViewportRebind = false;
634
+ viewportBoundSize = null;
622
635
  currentDeviceId = null;
623
636
  currentPhysicalDeviceId = null;
624
637
  currentLogicalDeviceId = null;
@@ -683,46 +696,43 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
683
696
 
684
697
  private void setupCamera() {
685
698
  ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(context);
686
- cameraProviderFuture.addListener(
687
- () -> {
688
- try {
689
- if (lifecycleRegistry.getCurrentState() == Lifecycle.State.DESTROYED) {
690
- if (listener != null) {
691
- listener.onCameraStartError(this, "Camera binding cancelled: lifecycle destroyed (before provider)");
692
- }
693
- return;
694
- }
695
- if (stopRequested) {
696
- if (listener != null) {
697
- listener.onCameraStartError(this, "Camera binding cancelled: stop requested (before provider)");
698
- }
699
- return;
699
+ cameraProviderFuture.addListener(() -> {
700
+ try {
701
+ if (lifecycleRegistry.getCurrentState() == Lifecycle.State.DESTROYED) {
702
+ if (listener != null) {
703
+ listener.onCameraStartError(this, "Camera binding cancelled: lifecycle destroyed (before provider)");
700
704
  }
701
- cameraProvider = cameraProviderFuture.get();
702
- if (lifecycleRegistry.getCurrentState() == Lifecycle.State.DESTROYED) {
703
- if (listener != null) {
704
- listener.onCameraStartError(this, "Camera binding cancelled: lifecycle destroyed (after provider)");
705
- }
706
- return;
705
+ return;
706
+ }
707
+ if (stopRequested) {
708
+ if (listener != null) {
709
+ listener.onCameraStartError(this, "Camera binding cancelled: stop requested (before provider)");
707
710
  }
708
- if (stopRequested) {
709
- if (listener != null) {
710
- listener.onCameraStartError(this, "Camera binding cancelled: stop requested (after provider)");
711
- }
712
- return;
711
+ return;
712
+ }
713
+ cameraProvider = cameraProviderFuture.get();
714
+ if (lifecycleRegistry.getCurrentState() == Lifecycle.State.DESTROYED) {
715
+ if (listener != null) {
716
+ listener.onCameraStartError(this, "Camera binding cancelled: lifecycle destroyed (after provider)");
713
717
  }
714
- setupPreviewView();
715
- bindCameraUseCases();
716
- } catch (Exception e) {
717
- // Restore webView background on error
718
- restoreWebViewBackground();
718
+ return;
719
+ }
720
+ if (stopRequested) {
719
721
  if (listener != null) {
720
- listener.onCameraStartError(this, "Error initializing camera: " + e.getMessage());
722
+ listener.onCameraStartError(this, "Camera binding cancelled: stop requested (after provider)");
721
723
  }
724
+ return;
722
725
  }
723
- },
724
- mainExecutor
725
- );
726
+ setupPreviewView();
727
+ bindCameraUseCases();
728
+ } catch (Exception e) {
729
+ // Restore webView background on error
730
+ restoreWebViewBackground();
731
+ if (listener != null) {
732
+ listener.onCameraStartError(this, "Error initializing camera: " + e.getMessage());
733
+ }
734
+ }
735
+ }, mainExecutor);
726
736
  }
727
737
 
728
738
  private void setupPreviewView() {
@@ -772,13 +782,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
772
782
  new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
773
783
  );
774
784
 
775
- previewView
776
- .getPreviewStreamState()
777
- .observe(this, (streamState) -> {
778
- if (sessionConfig != null && sessionConfig.isToBack() && streamState == PreviewView.StreamState.STREAMING) {
779
- notifyCameraStartedIfNeeded("streaming");
780
- }
781
- });
785
+ previewView.getPreviewStreamState().observe(this, (streamState) -> {
786
+ if (sessionConfig != null && sessionConfig.isToBack() && streamState == PreviewView.StreamState.STREAMING) {
787
+ notifyCameraStartedIfNeeded("streaming");
788
+ }
789
+ });
782
790
 
783
791
  // Create and setup the grid overlay
784
792
  gridOverlayView = new GridOverlayView(context);
@@ -801,6 +809,18 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
801
809
  if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) {
802
810
  Log.d(TAG, "PreviewView layout changed, updating grid bounds");
803
811
  updateGridOverlayBounds();
812
+ if (
813
+ isRunning &&
814
+ viewportCropEnabled &&
815
+ viewportBoundSize != null &&
816
+ (v.getWidth() != viewportBoundSize.getWidth() || v.getHeight() != viewportBoundSize.getHeight())
817
+ ) {
818
+ pendingViewportRebind = true;
819
+ if (!shouldDeferViewportRebind()) {
820
+ pendingViewportRebind = false;
821
+ bindCameraUseCases();
822
+ }
823
+ }
804
824
  }
805
825
  });
806
826
 
@@ -957,7 +977,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
957
977
  Log.d(TAG, "Device orientation: " + (isPortrait ? "PORTRAIT" : "LANDSCAPE"));
958
978
 
959
979
  // iOS: let ratio = !isPortrait ? ratioParts[0] / ratioParts[1] : ratioParts[1] / ratioParts[0]
960
- double ratio = !isPortrait ? (ratioWidth / ratioHeight) : (ratioHeight / ratioWidth);
980
+ double ratio = !isPortrait ? ratioWidth / ratioHeight : ratioHeight / ratioWidth;
961
981
 
962
982
  Log.d(TAG, "Computed ratio: " + ratio + " (iOS formula: " + (!isPortrait ? "width/height" : "height/width") + ")");
963
983
 
@@ -1032,6 +1052,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1032
1052
  }
1033
1053
  previewContainer = null;
1034
1054
  }
1055
+ clearViewportRebindListener();
1035
1056
  if (previewView != null) {
1036
1057
  previewView = null;
1037
1058
  }
@@ -1050,7 +1071,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1050
1071
  private void bindCameraUseCases() {
1051
1072
  if (cameraProvider == null) return;
1052
1073
  mainExecutor.execute(() -> {
1074
+ boolean acquiredRebindGuard = false;
1053
1075
  try {
1076
+ if (previewView != null && (previewView.getWidth() <= 0 || previewView.getHeight() <= 0) && !isRunning) {
1077
+ pendingViewportRebind = true;
1078
+ scheduleViewportRebindWhenLayoutReady();
1079
+ return;
1080
+ }
1081
+
1054
1082
  Log.d(
1055
1083
  TAG,
1056
1084
  "Building camera selector with deviceId: " +
@@ -1061,37 +1089,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1061
1089
  CameraBindingPlan bindingPlan = buildCameraBindingPlan(sessionConfig);
1062
1090
  currentCameraSelector = bindingPlan.selector;
1063
1091
 
1064
- ResolutionSelector.Builder resolutionSelectorBuilder = new ResolutionSelector.Builder().setResolutionStrategy(
1065
- ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY
1066
- );
1092
+ ResolutionSelector previewResolutionSelector = buildPreviewResolutionSelector();
1093
+ ResolutionSelector imageCaptureResolutionSelector = buildImageCaptureResolutionSelector();
1067
1094
 
1068
- if (sessionConfig.getAspectRatio() != null) {
1069
- int aspectRatio;
1070
- if ("16:9".equals(sessionConfig.getAspectRatio())) {
1071
- aspectRatio = AspectRatio.RATIO_16_9;
1072
- } else {
1073
- // "4:3"
1074
- aspectRatio = AspectRatio.RATIO_4_3;
1075
- }
1076
- resolutionSelectorBuilder.setAspectRatioStrategy(
1077
- new AspectRatioStrategy(aspectRatio, AspectRatioStrategy.FALLBACK_RULE_AUTO)
1078
- );
1079
- }
1080
-
1081
- ResolutionSelector resolutionSelector = resolutionSelectorBuilder.build();
1082
-
1083
- int rotation = previewView != null && previewView.getDisplay() != null
1084
- ? previewView.getDisplay().getRotation()
1085
- : android.view.Surface.ROTATION_0;
1095
+ int rotation =
1096
+ previewView != null && previewView.getDisplay() != null
1097
+ ? previewView.getDisplay().getRotation()
1098
+ : android.view.Surface.ROTATION_0;
1086
1099
 
1087
1100
  Preview.Builder previewBuilder = new Preview.Builder()
1088
- .setResolutionSelector(resolutionSelector)
1101
+ .setResolutionSelector(previewResolutionSelector)
1089
1102
  .setTargetRotation(rotation);
1090
1103
  previewBuilder = applyTargetFpsToPreviewBuilder(previewBuilder);
1091
1104
  Preview preview = previewBuilder.build();
1092
1105
  // Keep reference to preview use case for later re-binding (e.g., when enabling video)
1093
1106
  imageCapture = new ImageCapture.Builder()
1094
- .setResolutionSelector(resolutionSelector)
1107
+ .setResolutionSelector(imageCaptureResolutionSelector)
1095
1108
  .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
1096
1109
  .setFlashMode(currentFlashMode)
1097
1110
  .setTargetRotation(rotation)
@@ -1165,6 +1178,24 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1165
1178
  videoCapture = videoCaptureBuilder.build();
1166
1179
  }
1167
1180
 
1181
+ Integer exposureCompensationIndexToRestore = null;
1182
+ if (camera != null) {
1183
+ try {
1184
+ exposureCompensationIndexToRestore = camera.getCameraInfo().getExposureState().getExposureCompensationIndex();
1185
+ } catch (Exception e) {
1186
+ Log.w(TAG, "bindCameraUseCases: Failed to read exposure compensation before rebind", e);
1187
+ }
1188
+ }
1189
+
1190
+ synchronized (captureLock) {
1191
+ if (shouldDeferViewportRebind()) {
1192
+ pendingViewportRebind = true;
1193
+ return;
1194
+ }
1195
+ viewportRebindInFlight = true;
1196
+ acquiredRebindGuard = true;
1197
+ }
1198
+
1168
1199
  // Unbind any existing use cases and bind new ones
1169
1200
  cameraProvider.unbindAll();
1170
1201
 
@@ -1189,11 +1220,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1189
1220
 
1190
1221
  bindingPlan = buildLogicalFallbackPlan(sessionConfig, bindingPlan);
1191
1222
  currentCameraSelector = bindingPlan.selector;
1223
+ cameraProvider.unbindAll();
1192
1224
  bindConfiguredUseCases(bindingPlan, preview);
1193
1225
  }
1194
1226
 
1195
- resetExposureCompensationToDefault();
1196
- reapplyCameraControlModes();
1227
+ restoreCameraControlStateAfterRebind(exposureCompensationIndexToRestore);
1197
1228
 
1198
1229
  // Log details about the active camera
1199
1230
  Log.d(TAG, "Use cases bound. Inspecting active camera and use cases.");
@@ -1277,11 +1308,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1277
1308
  previewView.setScaleType("cover".equals(aspectMode) ? PreviewView.ScaleType.FILL_CENTER : PreviewView.ScaleType.FIT_CENTER);
1278
1309
 
1279
1310
  // Set initial zoom if specified, prioritizing targetZoom over default zoomFactor
1280
- float initialZoom = !bindingPlan.usesPhysicalSelection &&
1281
- bindingPlan.fallbackZoom != 1.0f &&
1282
- sessionConfig.getTargetZoom() == 1.0f
1283
- ? bindingPlan.fallbackZoom
1284
- : (sessionConfig.getTargetZoom() != 1.0f ? sessionConfig.getTargetZoom() : sessionConfig.getZoomFactor());
1311
+ float initialZoom =
1312
+ !bindingPlan.usesPhysicalSelection && bindingPlan.fallbackZoom != 1.0f && sessionConfig.getTargetZoom() == 1.0f
1313
+ ? bindingPlan.fallbackZoom
1314
+ : sessionConfig.getTargetZoom() != 1.0f
1315
+ ? sessionConfig.getTargetZoom()
1316
+ : sessionConfig.getZoomFactor();
1285
1317
  if (initialZoom != 1.0f) {
1286
1318
  Log.d(TAG, "Applying initial zoom of " + initialZoom);
1287
1319
 
@@ -1319,28 +1351,20 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1319
1351
  if (streamState == PreviewView.StreamState.STREAMING) {
1320
1352
  notifyCameraStartedIfNeeded("already-streaming");
1321
1353
  } else if (previewContainer != null) {
1322
- previewContainer.postDelayed(
1323
- () -> {
1324
- PreviewView.StreamState latestState = previewView != null
1325
- ? previewView.getPreviewStreamState().getValue()
1326
- : null;
1327
- if (latestState == PreviewView.StreamState.STREAMING) {
1328
- notifyCameraStartedIfNeeded("watchdog-streaming");
1329
- }
1330
- },
1331
- 300
1332
- );
1333
- previewContainer.postDelayed(
1334
- () -> {
1335
- PreviewView.StreamState latestState = previewView != null
1336
- ? previewView.getPreviewStreamState().getValue()
1337
- : null;
1338
- if (!cameraStartedCallbackSent && latestState == PreviewView.StreamState.STREAMING) {
1339
- notifyCameraStartedIfNeeded("fallback-streaming");
1340
- }
1341
- },
1342
- 1500
1343
- );
1354
+ previewContainer.postDelayed(() -> {
1355
+ PreviewView.StreamState latestState =
1356
+ previewView != null ? previewView.getPreviewStreamState().getValue() : null;
1357
+ if (latestState == PreviewView.StreamState.STREAMING) {
1358
+ notifyCameraStartedIfNeeded("watchdog-streaming");
1359
+ }
1360
+ }, 300);
1361
+ previewContainer.postDelayed(() -> {
1362
+ PreviewView.StreamState latestState =
1363
+ previewView != null ? previewView.getPreviewStreamState().getValue() : null;
1364
+ if (!cameraStartedCallbackSent && latestState == PreviewView.StreamState.STREAMING) {
1365
+ notifyCameraStartedIfNeeded("fallback-streaming");
1366
+ }
1367
+ }, 1500);
1344
1368
  }
1345
1369
  } else {
1346
1370
  notifyCameraStartedIfNeeded("bound");
@@ -1351,6 +1375,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1351
1375
  restoreWebViewBackground();
1352
1376
  completePendingFrameRateBindError("Error binding camera: " + e.getMessage());
1353
1377
  if (listener != null) listener.onCameraStartError(this, "Error binding camera: " + e.getMessage());
1378
+ } finally {
1379
+ if (acquiredRebindGuard) {
1380
+ synchronized (captureLock) {
1381
+ viewportRebindInFlight = false;
1382
+ }
1383
+ maybePerformPendingViewportRebind();
1384
+ }
1354
1385
  }
1355
1386
  });
1356
1387
  }
@@ -1637,13 +1668,216 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1637
1668
  return 1.0f;
1638
1669
  }
1639
1670
 
1671
+ private ResolutionSelector buildPreviewResolutionSelector() {
1672
+ ResolutionSelector.Builder resolutionSelectorBuilder = new ResolutionSelector.Builder().setResolutionStrategy(
1673
+ ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY
1674
+ );
1675
+ applySessionAspectRatioStrategy(resolutionSelectorBuilder);
1676
+ return resolutionSelectorBuilder.build();
1677
+ }
1678
+
1679
+ private ResolutionSelector buildImageCaptureResolutionSelector() {
1680
+ Size targetResolution = new Size(
1681
+ Math.max(DEFAULT_MAX_CAPTURE_RESOLUTION.getWidth(), DEFAULT_MAX_CAPTURE_RESOLUTION.getHeight()),
1682
+ Math.min(DEFAULT_MAX_CAPTURE_RESOLUTION.getWidth(), DEFAULT_MAX_CAPTURE_RESOLUTION.getHeight())
1683
+ );
1684
+ int maxCaptureLongEdge = Math.max(DEFAULT_MAX_CAPTURE_RESOLUTION.getWidth(), DEFAULT_MAX_CAPTURE_RESOLUTION.getHeight());
1685
+ ResolutionSelector.Builder resolutionSelectorBuilder = new ResolutionSelector.Builder();
1686
+ resolutionSelectorBuilder.setResolutionFilter((supportedSizes, rotationDegrees) ->
1687
+ filterCaptureResolutionCandidates(supportedSizes, maxCaptureLongEdge)
1688
+ );
1689
+ resolutionSelectorBuilder.setResolutionStrategy(
1690
+ new ResolutionStrategy(targetResolution, ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER_THEN_HIGHER)
1691
+ );
1692
+ applySessionAspectRatioStrategy(resolutionSelectorBuilder);
1693
+ return resolutionSelectorBuilder.build();
1694
+ }
1695
+
1696
+ private List<Size> filterCaptureResolutionCandidates(List<Size> supportedSizes, int maxCaptureLongEdge) {
1697
+ if (supportedSizes == null || supportedSizes.isEmpty()) {
1698
+ return supportedSizes;
1699
+ }
1700
+
1701
+ List<Size> cappedSizes = new ArrayList<>();
1702
+ for (Size size : supportedSizes) {
1703
+ int longEdge = Math.max(size.getWidth(), size.getHeight());
1704
+ if (longEdge <= maxCaptureLongEdge) {
1705
+ cappedSizes.add(size);
1706
+ }
1707
+ }
1708
+
1709
+ if (!cappedSizes.isEmpty()) {
1710
+ return cappedSizes;
1711
+ }
1712
+
1713
+ Size smallest = supportedSizes.get(0);
1714
+ int smallestLongEdge = Math.max(smallest.getWidth(), smallest.getHeight());
1715
+ for (Size size : supportedSizes) {
1716
+ int longEdge = Math.max(size.getWidth(), size.getHeight());
1717
+ if (longEdge < smallestLongEdge) {
1718
+ smallest = size;
1719
+ smallestLongEdge = longEdge;
1720
+ }
1721
+ }
1722
+ return Collections.singletonList(smallest);
1723
+ }
1724
+
1725
+ private void applySessionAspectRatioStrategy(ResolutionSelector.Builder resolutionSelectorBuilder) {
1726
+ if (sessionConfig == null || sessionConfig.getAspectRatio() == null) {
1727
+ return;
1728
+ }
1729
+
1730
+ int aspectRatio;
1731
+ if ("16:9".equals(sessionConfig.getAspectRatio())) {
1732
+ aspectRatio = AspectRatio.RATIO_16_9;
1733
+ } else {
1734
+ aspectRatio = AspectRatio.RATIO_4_3;
1735
+ }
1736
+ resolutionSelectorBuilder.setAspectRatioStrategy(new AspectRatioStrategy(aspectRatio, AspectRatioStrategy.FALLBACK_RULE_AUTO));
1737
+ }
1738
+
1739
+ private ViewPort buildViewPort(int rotation) {
1740
+ if (previewView == null || previewView.getWidth() <= 0 || previewView.getHeight() <= 0) {
1741
+ return null;
1742
+ }
1743
+ ViewPort viewPort = previewView.getViewPort(rotation);
1744
+ if (viewPort != null) {
1745
+ viewportBoundSize = new Size(previewView.getWidth(), previewView.getHeight());
1746
+ }
1747
+ return viewPort;
1748
+ }
1749
+
1750
+ private boolean isViewportCropCurrent() {
1751
+ return (
1752
+ viewportCropEnabled &&
1753
+ viewportBoundSize != null &&
1754
+ previewView != null &&
1755
+ previewView.getWidth() == viewportBoundSize.getWidth() &&
1756
+ previewView.getHeight() == viewportBoundSize.getHeight()
1757
+ );
1758
+ }
1759
+
1760
+ private boolean shouldDeferViewportRebind() {
1761
+ return isCapturingPhoto || currentRecording != null;
1762
+ }
1763
+
1764
+ private void clearViewportRebindListener() {
1765
+ if (previewView != null && viewportRebindListener != null) {
1766
+ previewView.removeOnLayoutChangeListener(viewportRebindListener);
1767
+ viewportRebindListener = null;
1768
+ }
1769
+ }
1770
+
1771
+ private void maybePerformPendingViewportRebind() {
1772
+ mainExecutor.execute(() -> {
1773
+ if (!pendingViewportRebind || !isRunning || shouldDeferViewportRebind()) {
1774
+ return;
1775
+ }
1776
+ pendingViewportRebind = false;
1777
+ bindCameraUseCases();
1778
+ });
1779
+ }
1780
+
1781
+ private void scheduleViewportRebindWhenLayoutReady() {
1782
+ if (previewView == null || !pendingViewportRebind || viewportRebindListener != null) {
1783
+ return;
1784
+ }
1785
+
1786
+ viewportRebindListener = new View.OnLayoutChangeListener() {
1787
+ @Override
1788
+ public void onLayoutChange(
1789
+ View v,
1790
+ int left,
1791
+ int top,
1792
+ int right,
1793
+ int bottom,
1794
+ int oldLeft,
1795
+ int oldTop,
1796
+ int oldRight,
1797
+ int oldBottom
1798
+ ) {
1799
+ if (!pendingViewportRebind || previewView == null || stopRequested) {
1800
+ v.removeOnLayoutChangeListener(this);
1801
+ viewportRebindListener = null;
1802
+ return;
1803
+ }
1804
+ if (previewView.getWidth() > 0 && previewView.getHeight() > 0) {
1805
+ v.removeOnLayoutChangeListener(this);
1806
+ viewportRebindListener = null;
1807
+ if (shouldDeferViewportRebind()) {
1808
+ Log.d(
1809
+ TAG,
1810
+ "scheduleViewportRebindWhenLayoutReady: Layout ready but deferring ViewPort rebind (capture/recording active)"
1811
+ );
1812
+ return;
1813
+ }
1814
+ Log.d(TAG, "scheduleViewportRebindWhenLayoutReady: PreviewView layout ready, rebinding with ViewPort");
1815
+ bindCameraUseCases();
1816
+ }
1817
+ }
1818
+ };
1819
+ previewView.addOnLayoutChangeListener(viewportRebindListener);
1820
+ }
1821
+
1822
+ private boolean captureRequiresSoftwareTransform(
1823
+ Integer width,
1824
+ Integer height,
1825
+ boolean embedTimestamp,
1826
+ boolean embedLocation,
1827
+ boolean mirrorFrontCamera
1828
+ ) {
1829
+ return width != null || height != null || embedTimestamp || embedLocation || shouldMirrorFrontCamera(mirrorFrontCamera);
1830
+ }
1831
+
1640
1832
  private void bindConfiguredUseCases(CameraBindingPlan bindingPlan, Preview preview) {
1641
- if (sessionConfig.isVideoModeEnabled() && videoCapture != null) {
1642
- camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture, videoCapture);
1643
- } else if (barcodeAnalysis != null) {
1644
- camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture, barcodeAnalysis);
1833
+ int rotation =
1834
+ previewView != null && previewView.getDisplay() != null
1835
+ ? previewView.getDisplay().getRotation()
1836
+ : android.view.Surface.ROTATION_0;
1837
+ ViewPort viewPort = buildViewPort(rotation);
1838
+
1839
+ if (viewPort != null) {
1840
+ UseCaseGroup.Builder groupBuilder = new UseCaseGroup.Builder()
1841
+ .setViewPort(viewPort)
1842
+ .addUseCase(preview)
1843
+ .addUseCase(imageCapture);
1844
+ if (sessionConfig.isVideoModeEnabled() && videoCapture != null) {
1845
+ groupBuilder.addUseCase(videoCapture);
1846
+ }
1847
+
1848
+ try {
1849
+ camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, groupBuilder.build());
1850
+ viewportCropEnabled = true;
1851
+ pendingViewportRebind = false;
1852
+ Log.d(TAG, "bindConfiguredUseCases: Bound use cases with shared ViewPort");
1853
+ } catch (Exception viewportGroupError) {
1854
+ Log.w(TAG, "bindConfiguredUseCases: ViewPort group binding failed; retrying without ViewPort", viewportGroupError);
1855
+ cameraProvider.unbindAll();
1856
+ viewportCropEnabled = false;
1857
+ viewportBoundSize = null;
1858
+ pendingViewportRebind = false;
1859
+ if (sessionConfig.isVideoModeEnabled() && videoCapture != null) {
1860
+ camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture, videoCapture);
1861
+ } else {
1862
+ camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture);
1863
+ }
1864
+ }
1645
1865
  } else {
1646
- camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture);
1866
+ viewportCropEnabled = false;
1867
+ viewportBoundSize = null;
1868
+ pendingViewportRebind = true;
1869
+ scheduleViewportRebindWhenLayoutReady();
1870
+ Log.w(TAG, "bindConfiguredUseCases: ViewPort unavailable, falling back to legacy binding without viewport crop");
1871
+
1872
+ if (sessionConfig.isVideoModeEnabled() && videoCapture != null) {
1873
+ camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture, videoCapture);
1874
+ } else {
1875
+ camera = cameraProvider.bindToLifecycle(this, bindingPlan.selector, preview, imageCapture);
1876
+ }
1877
+ }
1878
+
1879
+ if (barcodeAnalysis != null) {
1880
+ cameraProvider.bindToLifecycle(this, bindingPlan.selector, barcodeAnalysis);
1647
1881
  }
1648
1882
 
1649
1883
  CameraInfo cameraInfo = camera.getCameraInfo();
@@ -2078,6 +2312,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2078
2312
  boolean dispatched = false;
2079
2313
  try {
2080
2314
  synchronized (captureLock) {
2315
+ if (viewportRebindInFlight) {
2316
+ if (listener != null) {
2317
+ listener.onPictureTakenError("Camera is reconfiguring, try again");
2318
+ }
2319
+ return;
2320
+ }
2081
2321
  isCapturingPhoto = true;
2082
2322
  }
2083
2323
 
@@ -2107,6 +2347,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2107
2347
  performImmediateStop();
2108
2348
  }
2109
2349
  }
2350
+ maybePerformPendingViewportRebind();
2110
2351
  endOperation("capturePhoto");
2111
2352
  }
2112
2353
 
@@ -2125,7 +2366,23 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2125
2366
  // Build EXIF JSON from captured bytes (location applied by metadata if provided)
2126
2367
  JSONObject exifData = getExifData(exifInterface);
2127
2368
 
2128
- if (width != null || height != null) {
2369
+ boolean requiresSoftwareTransform = captureRequiresSoftwareTransform(
2370
+ width,
2371
+ height,
2372
+ embedTimestamp,
2373
+ embedLocation,
2374
+ mirrorFrontCamera
2375
+ );
2376
+
2377
+ if (
2378
+ !requiresSoftwareTransform &&
2379
+ isViewportCropCurrent() &&
2380
+ quality == 95 &&
2381
+ exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) ==
2382
+ ExifInterface.ORIENTATION_NORMAL
2383
+ ) {
2384
+ Log.d(TAG, "capturePhoto: Using ViewPort fast path without software decode/crop/re-encode");
2385
+ } else if (width != null || height != null) {
2129
2386
  Bitmap bitmap = BitmapFactory.decodeByteArray(originalCaptureBytes, 0, originalCaptureBytes.length);
2130
2387
  bitmap = applyExifOrientation(bitmap, exifInterface);
2131
2388
  bitmap = maybeMirrorFrontCameraBitmap(bitmap, mirrorFrontCamera);
@@ -2154,39 +2411,44 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2154
2411
  finalWidthOut = resizedBitmap.getWidth();
2155
2412
  finalHeightOut = resizedBitmap.getHeight();
2156
2413
  } else {
2157
- // No explicit size/ratio: crop to match current preview content
2414
+ // No explicit size: crop to preview unless ViewPort already matched FOV
2158
2415
  Bitmap originalBitmap = BitmapFactory.decodeByteArray(originalCaptureBytes, 0, originalCaptureBytes.length);
2159
2416
  originalBitmap = applyExifOrientation(originalBitmap, exifInterface);
2160
2417
  originalBitmap = maybeMirrorFrontCameraBitmap(originalBitmap, mirrorFrontCamera);
2161
- Bitmap previewCropped = cropBitmapToMatchPreview(originalBitmap);
2418
+ Bitmap processedBitmap = isViewportCropCurrent()
2419
+ ? originalBitmap
2420
+ : cropBitmapToMatchPreview(originalBitmap);
2421
+ if (isViewportCropCurrent()) {
2422
+ Log.d(TAG, "capturePhoto: Skipping software preview crop (ViewPort active)");
2423
+ }
2162
2424
  if (embedTimestamp || embedLocation) {
2163
- previewCropped = drawTimestampAndLocationOntoBitmap(
2164
- previewCropped,
2425
+ processedBitmap = drawTimestampAndLocationOntoBitmap(
2426
+ processedBitmap,
2165
2427
  exifInterface,
2166
2428
  embedTimestamp,
2167
2429
  embedLocation
2168
2430
  );
2169
2431
  }
2170
2432
  ByteArrayOutputStream stream = new ByteArrayOutputStream();
2171
- previewCropped.compress(Bitmap.CompressFormat.JPEG, quality, stream);
2433
+ processedBitmap.compress(Bitmap.CompressFormat.JPEG, quality, stream);
2172
2434
  bytes = stream.toByteArray();
2173
2435
  transformedPixels = true;
2174
2436
  // Update EXIF JSON to reflect cropped dimensions; no in-place EXIF write to bytes
2175
2437
  try {
2176
- exifData.put("PixelXDimension", previewCropped.getWidth());
2177
- exifData.put("PixelYDimension", previewCropped.getHeight());
2178
- exifData.put("ImageWidth", previewCropped.getWidth());
2179
- exifData.put("ImageLength", previewCropped.getHeight());
2438
+ exifData.put("PixelXDimension", processedBitmap.getWidth());
2439
+ exifData.put("PixelYDimension", processedBitmap.getHeight());
2440
+ exifData.put("ImageWidth", processedBitmap.getWidth());
2441
+ exifData.put("ImageLength", processedBitmap.getHeight());
2180
2442
  exifData.put("Orientation", Integer.toString(ExifInterface.ORIENTATION_NORMAL));
2181
2443
  } catch (Exception ignore) {}
2182
- finalWidthOut = previewCropped.getWidth();
2183
- finalHeightOut = previewCropped.getHeight();
2444
+ finalWidthOut = processedBitmap.getWidth();
2445
+ finalHeightOut = processedBitmap.getHeight();
2184
2446
  }
2185
2447
 
2186
2448
  // After any transform, inject EXIF back into the in-memory JPEG bytes (no temp file)
2187
2449
  if (transformedPixels) {
2188
- Integer fW = (finalWidthOut > 0) ? finalWidthOut : null;
2189
- Integer fH = (finalHeightOut > 0) ? finalHeightOut : null;
2450
+ Integer fW = finalWidthOut > 0 ? finalWidthOut : null;
2451
+ Integer fH = finalHeightOut > 0 ? finalHeightOut : null;
2190
2452
  bytes = injectExifInMemory(bytes, originalCaptureBytes, fW, fH);
2191
2453
  }
2192
2454
 
@@ -2205,8 +2467,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2205
2467
  if (saveToGallery) {
2206
2468
  final byte[] finalBytes = bytes;
2207
2469
  final ExifInterface exifForFile = exifInterface;
2208
- final Integer fW = (finalWidthOut > 0) ? finalWidthOut : null;
2209
- final Integer fH = (finalHeightOut > 0) ? finalHeightOut : null;
2470
+ final Integer fW = finalWidthOut > 0 ? finalWidthOut : null;
2471
+ final Integer fH = finalHeightOut > 0 ? finalHeightOut : null;
2210
2472
  new Thread(() -> saveImageToGallery(finalBytes, exifForFile, fW, fH)).start();
2211
2473
  }
2212
2474
 
@@ -2253,6 +2515,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2253
2515
  performImmediateStop();
2254
2516
  }
2255
2517
  }
2518
+ maybePerformPendingViewportRebind();
2256
2519
  endOperation("capturePhoto");
2257
2520
  }
2258
2521
  }
@@ -2273,6 +2536,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2273
2536
  performImmediateStop();
2274
2537
  }
2275
2538
  }
2539
+ maybePerformPendingViewportRebind();
2276
2540
  endOperation("capturePhoto");
2277
2541
  }
2278
2542
  }
@@ -2283,7 +2547,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2283
2547
 
2284
2548
  // Build strings (null-safe)
2285
2549
  final String when = embedTimestamp ? buildTimestampStringFromExif(exif) : null;
2286
- final String where = (embedLocation ? buildLocationStringFromExif(exif) : null);
2550
+ final String where = embedLocation ? buildLocationStringFromExif(exif) : null;
2287
2551
 
2288
2552
  // Nothing to draw?
2289
2553
  if ((when == null || when.isEmpty()) && (where == null || where.isEmpty())) {
@@ -2357,7 +2621,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2357
2621
  if (where != null && !where.isEmpty()) {
2358
2622
  // If there was no timestamp drawn, we still start at top margin.
2359
2623
  // If there was, we use the accumulated nextTop (= bottom + gap).
2360
- drawPill.apply(where, (when != null && !when.isEmpty()) ? nextTop : margin);
2624
+ drawPill.apply(where, when != null && !when.isEmpty() ? nextTop : margin);
2361
2625
  }
2362
2626
 
2363
2627
  return bmp;
@@ -2461,30 +2725,32 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2461
2725
  int originalWidth = bitmap.getWidth();
2462
2726
  int originalHeight = bitmap.getHeight();
2463
2727
  float originalAspectRatio = (float) originalWidth / originalHeight;
2728
+ int boundedMaxWidth = maxWidth != null ? Math.min(maxWidth, originalWidth) : originalWidth;
2729
+ int boundedMaxHeight = maxHeight != null ? Math.min(maxHeight, originalHeight) : originalHeight;
2464
2730
 
2465
2731
  int targetWidth;
2466
2732
  int targetHeight = originalHeight;
2467
2733
 
2468
2734
  if (maxWidth != null && maxHeight != null) {
2469
2735
  // Both dimensions specified - fit within both maximums
2470
- float maxAspectRatio = (float) maxWidth / maxHeight;
2736
+ float maxAspectRatio = (float) boundedMaxWidth / boundedMaxHeight;
2471
2737
  if (originalAspectRatio > maxAspectRatio) {
2472
2738
  // Original is wider - fit by width
2473
- targetWidth = maxWidth;
2474
- targetHeight = (int) (maxWidth / originalAspectRatio);
2739
+ targetWidth = boundedMaxWidth;
2740
+ targetHeight = (int) (boundedMaxWidth / originalAspectRatio);
2475
2741
  } else {
2476
2742
  // Original is taller - fit by height
2477
- targetWidth = (int) (maxHeight * originalAspectRatio);
2478
- targetHeight = maxHeight;
2743
+ targetWidth = (int) (boundedMaxHeight * originalAspectRatio);
2744
+ targetHeight = boundedMaxHeight;
2479
2745
  }
2480
2746
  } else if (maxWidth != null) {
2481
2747
  // Only width specified - maintain aspect ratio
2482
- targetWidth = maxWidth;
2483
- targetHeight = (int) (maxWidth / originalAspectRatio);
2748
+ targetWidth = boundedMaxWidth;
2749
+ targetHeight = (int) (boundedMaxWidth / originalAspectRatio);
2484
2750
  } else {
2485
2751
  // Only height specified - maintain aspect ratio
2486
- targetWidth = (int) (maxHeight * originalAspectRatio);
2487
- targetHeight = maxHeight;
2752
+ targetWidth = (int) (boundedMaxHeight * originalAspectRatio);
2753
+ targetHeight = boundedMaxHeight;
2488
2754
  }
2489
2755
 
2490
2756
  return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true);
@@ -2528,9 +2794,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2528
2794
  );
2529
2795
  org.apache.commons.imaging.formats.tiff.TiffImageMetadata exif = jpegMetadata != null ? jpegMetadata.getExif() : null;
2530
2796
 
2531
- org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet = exif != null
2532
- ? exif.getOutputSet()
2533
- : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
2797
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet =
2798
+ exif != null ? exif.getOutputSet() : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
2534
2799
 
2535
2800
  // Update orientation if requested (normalize to 1)
2536
2801
  org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory rootDir = outputSet.getOrCreateRootDirectory();
@@ -2565,9 +2830,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2565
2830
  (org.apache.commons.imaging.formats.jpeg.JpegImageMetadata) org.apache.commons.imaging.Imaging.getMetadata(jpeg);
2566
2831
  org.apache.commons.imaging.formats.tiff.TiffImageMetadata exif = jpegMetadata != null ? jpegMetadata.getExif() : null;
2567
2832
 
2568
- org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet = exif != null
2569
- ? exif.getOutputSet()
2570
- : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
2833
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet =
2834
+ exif != null ? exif.getOutputSet() : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
2571
2835
 
2572
2836
  org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory gpsDir = outputSet.getOrCreateGpsDirectory();
2573
2837
 
@@ -2902,8 +3166,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2902
3166
  int previewH = Math.max(1, bounds.height());
2903
3167
 
2904
3168
  // Check if device physical orientation differs from UI orientation
2905
- int rotation = (lastCaptureRotation != -1) ? lastCaptureRotation : getRotationFromAccelerometer();
2906
- boolean physicalInLandscape = (rotation == android.view.Surface.ROTATION_90 || rotation == android.view.Surface.ROTATION_270);
3169
+ int rotation = lastCaptureRotation != -1 ? lastCaptureRotation : getRotationFromAccelerometer();
3170
+ boolean physicalInLandscape = rotation == android.view.Surface.ROTATION_90 || rotation == android.view.Surface.ROTATION_270;
2907
3171
  boolean previewIsPortrait = previewH > previewW;
2908
3172
 
2909
3173
  // If physical orientation doesn't match preview orientation swap ratio
@@ -3070,7 +3334,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3070
3334
  physicalMaxZoom = zoomRange.getUpper();
3071
3335
  }
3072
3336
  }
3073
- float physicalFocalLength = (focalLengths != null && focalLengths.length > 0) ? focalLengths[0] : 4.25f;
3337
+ float physicalFocalLength = focalLengths != null && focalLengths.length > 0 ? focalLengths[0] : 4.25f;
3074
3338
  String physicalLabel = "Physical " + physicalDeviceType + " (" + position + ")";
3075
3339
  List<LensInfo> physicalLenses = new ArrayList<>();
3076
3340
  physicalLenses.add(new LensInfo(physicalFocalLength, physicalDeviceType, 1.0f, physicalMaxZoom));
@@ -3279,32 +3543,29 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3279
3543
 
3280
3544
  final ListenableFuture<FocusMeteringResult> capturedFuture = future;
3281
3545
  final long tokenForListener = indicatorToken;
3282
- future.addListener(
3283
- () -> {
3284
- try {
3285
- FocusMeteringResult result = capturedFuture.get();
3286
- } catch (Exception e) {
3287
- // Handle cancellation gracefully - this is expected when rapid taps occur
3288
- if (
3289
- e.getMessage() != null &&
3290
- (e.getMessage().contains("Cancelled by another startFocusAndMetering") ||
3291
- e.getMessage().contains("OperationCanceledException") ||
3292
- e.getClass().getSimpleName().contains("OperationCanceledException"))
3293
- ) {
3294
- Log.d(TAG, "Focus operation was cancelled by a newer focus request");
3295
- } else {
3296
- Log.e(TAG, "Error during focus: " + e.getMessage());
3297
- }
3298
- } finally {
3299
- if (currentFocusFuture == capturedFuture && currentFocusFuture.isDone()) {
3300
- currentFocusFuture = null;
3301
- }
3302
- hideFocusIndicator(tokenForListener);
3303
- endOperation("setFocus");
3546
+ future.addListener(() -> {
3547
+ try {
3548
+ FocusMeteringResult result = capturedFuture.get();
3549
+ } catch (Exception e) {
3550
+ // Handle cancellation gracefully - this is expected when rapid taps occur
3551
+ if (
3552
+ e.getMessage() != null &&
3553
+ (e.getMessage().contains("Cancelled by another startFocusAndMetering") ||
3554
+ e.getMessage().contains("OperationCanceledException") ||
3555
+ e.getClass().getSimpleName().contains("OperationCanceledException"))
3556
+ ) {
3557
+ Log.d(TAG, "Focus operation was cancelled by a newer focus request");
3558
+ } else {
3559
+ Log.e(TAG, "Error during focus: " + e.getMessage());
3304
3560
  }
3305
- },
3306
- ContextCompat.getMainExecutor(context)
3307
- );
3561
+ } finally {
3562
+ if (currentFocusFuture == capturedFuture && currentFocusFuture.isDone()) {
3563
+ currentFocusFuture = null;
3564
+ }
3565
+ hideFocusIndicator(tokenForListener);
3566
+ endOperation("setFocus");
3567
+ }
3568
+ }, ContextCompat.getMainExecutor(context));
3308
3569
  } catch (Exception e) {
3309
3570
  currentFocusFuture = null;
3310
3571
  Log.e(TAG, "Failed to set focus: " + e.getMessage());
@@ -3484,6 +3745,38 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3484
3745
  }
3485
3746
  }
3486
3747
 
3748
+ private void restoreCameraControlStateAfterRebind(Integer exposureCompensationIndex) {
3749
+ if (camera == null) {
3750
+ return;
3751
+ }
3752
+ if (exposureCompensationIndex != null) {
3753
+ try {
3754
+ ExposureState state = camera.getCameraInfo().getExposureState();
3755
+ Range<Integer> range = state.getExposureCompensationRange();
3756
+ int idx = exposureCompensationIndex;
3757
+ if (idx < range.getLower()) {
3758
+ idx = range.getLower();
3759
+ }
3760
+ if (idx > range.getUpper()) {
3761
+ idx = range.getUpper();
3762
+ }
3763
+ camera.getCameraControl().setExposureCompensationIndex(idx);
3764
+ } catch (Exception e) {
3765
+ Log.w(TAG, "restoreCameraControlStateAfterRebind: Failed to restore exposure compensation", e);
3766
+ }
3767
+ } else {
3768
+ resetExposureCompensationToDefault();
3769
+ }
3770
+ reapplyCameraControlModes();
3771
+ if (torchRequested) {
3772
+ try {
3773
+ camera.getCameraControl().enableTorch(true);
3774
+ } catch (Exception e) {
3775
+ Log.w(TAG, "restoreCameraControlStateAfterRebind: Failed to restore torch", e);
3776
+ }
3777
+ }
3778
+ }
3779
+
3487
3780
  public float[] getExposureCompensationRange() throws Exception {
3488
3781
  if (camera == null) {
3489
3782
  throw new Exception("Camera not initialized");
@@ -3613,6 +3906,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3613
3906
  });
3614
3907
  }
3615
3908
 
3909
+ private void rejectPendingFrameRateBindOnStop() {
3910
+ completePendingFrameRateBindError("Camera session stopped before camera rebind completed");
3911
+ }
3912
+
3616
3913
  private void completePendingFrameRateBindSuccess() {
3617
3914
  if (pendingFrameRateBindSuccess == null) {
3618
3915
  return;
@@ -3892,7 +4189,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3892
4189
  if (parent != null) {
3893
4190
  parent.removeView(focusIndicatorView);
3894
4191
  }
3895
- } catch (Exception ignore) {} finally {
4192
+ } catch (Exception ignore) {
4193
+ } finally {
3896
4194
  focusIndicatorView = null;
3897
4195
  }
3898
4196
  });
@@ -3979,6 +4277,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3979
4277
  public void setFlashMode(String mode) {
3980
4278
  // Handle torch separately via CameraControl
3981
4279
  if ("torch".equals(mode)) {
4280
+ torchRequested = true;
3982
4281
  try {
3983
4282
  if (camera != null) {
3984
4283
  camera.getCameraControl().enableTorch(true);
@@ -3997,6 +4296,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3997
4296
  return;
3998
4297
  }
3999
4298
 
4299
+ torchRequested = false;
4000
4300
  // For non-torch modes, ensure torch is disabled
4001
4301
  try {
4002
4302
  if (camera != null) {
@@ -4858,7 +5158,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4858
5158
  Log.d(TAG, "Device orientation: " + (isPortrait ? "PORTRAIT" : "LANDSCAPE"));
4859
5159
 
4860
5160
  // iOS: let ratio = !isPortrait ? ratioParts[0] / ratioParts[1] : ratioParts[1] / ratioParts[0]
4861
- double ratio = !isPortrait ? (ratioWidth / ratioHeight) : (ratioHeight / ratioWidth);
5161
+ double ratio = !isPortrait ? ratioWidth / ratioHeight : ratioHeight / ratioWidth;
4862
5162
 
4863
5163
  Log.d(TAG, "Computed ratio: " + ratio + " (iOS formula: " + (!isPortrait ? "width/height" : "height/width") + ")");
4864
5164
 
@@ -4907,7 +5207,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4907
5207
  // Compare with expected ratio based on orientation
4908
5208
  String[] parts = aspectRatio.split(":");
4909
5209
  if (parts.length == 2) {
4910
- double expectedDisplayRatio = isPortrait ? (ratioHeight / ratioWidth) : (ratioWidth / ratioHeight);
5210
+ double expectedDisplayRatio = isPortrait ? ratioHeight / ratioWidth : ratioWidth / ratioHeight;
4911
5211
  double difference = Math.abs(displayedRatio - expectedDisplayRatio);
4912
5212
  Log.d(
4913
5213
  TAG,
@@ -5190,6 +5490,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
5190
5490
  currentRecording = null;
5191
5491
  currentVideoFile = null;
5192
5492
  currentVideoCallback = null;
5493
+ maybePerformPendingViewportRebind();
5193
5494
  }
5194
5495
 
5195
5496
  private boolean isRecordingLimitReached(int error) {