@capgo/camera-preview 8.1.4 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -172,6 +172,29 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
172
172
  private final Object accelerometerLock = new Object();
173
173
  private volatile int lastCaptureRotation = -1; // -1 unknown
174
174
 
175
+ // Compass heading (degrees, 0-360, true north); -1 means not yet available
176
+ private Sensor rotationVectorSensor;
177
+ private volatile float lastCompassHeading = -1f;
178
+
179
+ private final SensorEventListener rotationVectorListener = new SensorEventListener() {
180
+ @Override
181
+ public void onSensorChanged(SensorEvent event) {
182
+ if (event.sensor.getType() == Sensor.TYPE_ROTATION_VECTOR) {
183
+ float[] rotationMatrix = new float[9];
184
+ SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values);
185
+ float[] orientation = new float[3];
186
+ SensorManager.getOrientation(rotationMatrix, orientation);
187
+ float azimuthDeg = (float) Math.toDegrees(orientation[0]);
188
+ lastCompassHeading = (azimuthDeg + 360) % 360;
189
+ }
190
+ }
191
+
192
+ @Override
193
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
194
+ // Not needed
195
+ }
196
+ };
197
+
175
198
  private final SensorEventListener accelerometerListener = new SensorEventListener() {
176
199
  @Override
177
200
  public void onSensorChanged(SensorEvent event) {
@@ -382,10 +405,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
382
405
  if (sensorManager == null) {
383
406
  sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
384
407
  accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
408
+ rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
385
409
  }
386
410
  if (accelerometer != null) {
387
411
  sensorManager.registerListener(accelerometerListener, accelerometer, SensorManager.SENSOR_DELAY_UI);
388
412
  }
413
+ if (rotationVectorSensor != null) {
414
+ sensorManager.registerListener(rotationVectorListener, rotationVectorSensor, SensorManager.SENSOR_DELAY_NORMAL);
415
+ }
416
+ lastCompassHeading = -1f;
389
417
  synchronized (operationLock) {
390
418
  activeOperations = 0;
391
419
  stopPending = false;
@@ -436,10 +464,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
436
464
 
437
465
  private void performImmediateStop() {
438
466
  isRunning = false;
439
- // Stop accelerometer
467
+ // Stop accelerometer and rotation vector sensor
440
468
  if (sensorManager != null && accelerometer != null) {
441
469
  sensorManager.unregisterListener(accelerometerListener);
442
470
  }
471
+ if (sensorManager != null && rotationVectorSensor != null) {
472
+ sensorManager.unregisterListener(rotationVectorListener);
473
+ }
443
474
  // Cancel any ongoing focus operation when stopping session
444
475
  if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
445
476
  currentFocusFuture.cancel(true);
@@ -1222,6 +1253,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1222
1253
  int finalWidthOut = -1;
1223
1254
  int finalHeightOut = -1;
1224
1255
  boolean transformedPixels = false;
1256
+ // Snapshot compass heading at capture time for EXIF injection
1257
+ final float captureCompassHeading = lastCompassHeading;
1225
1258
 
1226
1259
  ExifInterface exifInterface = new ExifInterface(new ByteArrayInputStream(originalCaptureBytes));
1227
1260
  // Build EXIF JSON from captured bytes (location applied by metadata if provided)
@@ -1290,6 +1323,17 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1290
1323
  bytes = injectExifInMemory(bytes, originalCaptureBytes, fW, fH);
1291
1324
  }
1292
1325
 
1326
+ // Inject GPS image direction (compass heading) when a location was requested
1327
+ if (location != null && captureCompassHeading >= 0) {
1328
+ bytes = injectGpsHeadingIntoExif(bytes, captureCompassHeading);
1329
+ try {
1330
+ exifData.put("GPSImgDirection", String.valueOf(captureCompassHeading));
1331
+ exifData.put("GPSImgDirectionRef", "T");
1332
+ } catch (Exception e) {
1333
+ Log.d(TAG, "capturePhoto: Failed to update EXIF JSON with heading data", e);
1334
+ }
1335
+ }
1336
+
1293
1337
  // Save to gallery asynchronously if requested, copy EXIF to file
1294
1338
  if (saveToGallery) {
1295
1339
  final byte[] finalBytes = bytes;
@@ -1624,6 +1668,44 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1624
1668
  }
1625
1669
  }
1626
1670
 
1671
+ // Inject GPS image direction (compass heading) into a JPEG in memory using Apache Commons Imaging
1672
+ private byte[] injectGpsHeadingIntoExif(byte[] jpeg, float headingDegrees) {
1673
+ try {
1674
+ org.apache.commons.imaging.formats.jpeg.JpegImageMetadata jpegMetadata =
1675
+ (org.apache.commons.imaging.formats.jpeg.JpegImageMetadata) org.apache.commons.imaging.Imaging.getMetadata(jpeg);
1676
+ org.apache.commons.imaging.formats.tiff.TiffImageMetadata exif = jpegMetadata != null ? jpegMetadata.getExif() : null;
1677
+
1678
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet = exif != null
1679
+ ? exif.getOutputSet()
1680
+ : new org.apache.commons.imaging.formats.tiff.write.TiffOutputSet();
1681
+
1682
+ org.apache.commons.imaging.formats.tiff.write.TiffOutputDirectory gpsDir = outputSet.getOrCreateGpsDirectory();
1683
+
1684
+ gpsDir.removeField(org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants.GPS_TAG_GPS_IMG_DIRECTION_REF);
1685
+ gpsDir.add(
1686
+ org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants.GPS_TAG_GPS_IMG_DIRECTION_REF,
1687
+ org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants.GPS_TAG_GPS_IMG_DIRECTION_REF_VALUE_MAGNETIC_NORTH
1688
+ );
1689
+
1690
+ gpsDir.removeField(org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants.GPS_TAG_GPS_IMG_DIRECTION);
1691
+ gpsDir.add(
1692
+ org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants.GPS_TAG_GPS_IMG_DIRECTION,
1693
+ org.apache.commons.imaging.common.RationalNumber.valueOf(headingDegrees)
1694
+ );
1695
+
1696
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1697
+ new org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter().updateExifMetadataLossless(
1698
+ new java.io.ByteArrayInputStream(jpeg),
1699
+ out,
1700
+ outputSet
1701
+ );
1702
+ return out.toByteArray();
1703
+ } catch (Throwable t) {
1704
+ Log.w(TAG, "injectGpsHeadingIntoExif: Failed to inject heading EXIF", t);
1705
+ return jpeg;
1706
+ }
1707
+ }
1708
+
1627
1709
  private void updateResizedDimensions(
1628
1710
  org.apache.commons.imaging.formats.tiff.write.TiffOutputSet outputSet,
1629
1711
  Integer finalWidth,
@@ -2251,10 +2333,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2251
2333
  MeteringPointFactory factory = previewView.getMeteringPointFactory();
2252
2334
  MeteringPoint point = factory.createPoint(x * viewWidth, y * viewHeight);
2253
2335
 
2254
- // Create focus and metering action (persistent, no auto-cancel) to match iOS behavior
2255
- FocusMeteringAction action = new FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE)
2256
- .disableAutoCancel()
2257
- .build();
2336
+ // Create focus and metering action (resets after time to allow for auto focusing on movement later)
2337
+ FocusMeteringAction action = new FocusMeteringAction.Builder(
2338
+ point,
2339
+ FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE
2340
+ ).build();
2258
2341
 
2259
2342
  if (IsOperationRunning("setFocus")) {
2260
2343
  Log.d(TAG, "setFocus: Ignored because stop is pending");
@@ -56,6 +56,43 @@ class CameraController: NSObject {
56
56
  }
57
57
  }
58
58
 
59
+ // Continuous focus with significant movement if focus was locked from setFocus earlier
60
+ @objc private func subjectAreaDidChange(notification: NSNotification) {
61
+ guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
62
+
63
+ do {
64
+ try device.lockForConfiguration()
65
+ defer { device.unlockForConfiguration() }
66
+
67
+ // Reset Focus to the center and make it continuous
68
+ if device.isFocusModeSupported(.continuousAutoFocus) {
69
+ device.focusMode = .continuousAutoFocus
70
+ if device.isFocusPointOfInterestSupported {
71
+ device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5)
72
+ }
73
+ }
74
+
75
+ // 2. Reset Exposure to the center ONLY if it is not explicitly locked
76
+ if device.exposureMode != .locked {
77
+ if device.isExposureModeSupported(.continuousAutoExposure) {
78
+ device.exposureMode = .continuousAutoExposure
79
+ if device.isExposurePointOfInterestSupported {
80
+ device.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5)
81
+ }
82
+ device.setExposureTargetBias(0.0) { _ in }
83
+ }
84
+ }
85
+
86
+ // 3. Turn off monitoring until the user taps to focus again
87
+ device.isSubjectAreaChangeMonitoringEnabled = false
88
+
89
+ print("[CameraPreview] Phone moved: Reset focus. Exposure reset skipped if locked.")
90
+
91
+ } catch {
92
+ print("[CameraPreview] Failed to reset focus after subject area change: \(error)")
93
+ }
94
+ }
95
+
59
96
  var captureSession: AVCaptureSession?
60
97
  var disableFocusIndicator: Bool = false
61
98
 
@@ -446,6 +483,10 @@ extension CameraController {
446
483
  // Set initial zoom
447
484
  self.setInitialZoom(level: initialZoomLevel)
448
485
 
486
+ // Set up listener for change in subject area of camera feed
487
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
488
+ NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
489
+
449
490
  // Start the session - all outputs are already configured
450
491
  captureSession.startRunning()
451
492
 
@@ -1827,6 +1868,9 @@ extension CameraController {
1827
1868
  }
1828
1869
  }
1829
1870
 
1871
+ // Turn on subject area monitor for switch to continuous focus if needed
1872
+ device.isSubjectAreaChangeMonitoringEnabled = true
1873
+
1830
1874
  device.unlockForConfiguration()
1831
1875
  } catch {
1832
1876
  throw CameraControllerError.unknown
@@ -1999,6 +2043,8 @@ extension CameraController {
1999
2043
  captureSession.inputs.forEach { captureSession.removeInput($0) }
2000
2044
  captureSession.outputs.forEach { captureSession.removeOutput($0) }
2001
2045
  }
2046
+ // Remove listener for subject area change
2047
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
2002
2048
 
2003
2049
  self.motionManager.stopAccelerometerUpdates()
2004
2050
  self.previewLayer?.removeFromSuperlayer()
@@ -2240,7 +2286,7 @@ extension CameraController {
2240
2286
  if connection.isEnabled == false { connection.isEnabled = true }
2241
2287
  // Goes off accelerometer now
2242
2288
  connection.videoOrientation = self.getPhysicalOrientation()
2243
-
2289
+
2244
2290
  // Front camera: mirror the recorded video so it looks natural (selfie style).
2245
2291
  if self.currentCameraPosition == .front, connection.isVideoMirroringSupported {
2246
2292
  connection.isVideoMirrored = true
@@ -2317,6 +2363,10 @@ extension CameraController: UIGestureRecognizerDelegate {
2317
2363
  device.setExposureTargetBias(0.0) { _ in }
2318
2364
  }
2319
2365
  }
2366
+
2367
+ // Turn on subject area monitor for switch to continuous focus if needed
2368
+ device.isSubjectAreaChangeMonitoringEnabled = true
2369
+
2320
2370
  } catch {
2321
2371
  debugPrint(error)
2322
2372
  }
@@ -34,7 +34,7 @@ extension UIWindow {
34
34
  */
35
35
  @objc(CameraPreview)
36
36
  public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
37
- private let pluginVersion: String = "8.1.4"
37
+ private let pluginVersion: String = "8.2.0"
38
38
  public let identifier = "CameraPreviewPlugin"
39
39
  public let jsName = "CameraPreview"
40
40
  public let pluginMethods: [CAPPluginMethod] = [
@@ -100,6 +100,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
100
100
  var disableFocusIndicator: Bool = false
101
101
  var locationManager: CLLocationManager?
102
102
  var currentLocation: CLLocation?
103
+ var currentHeading: CLHeading?
103
104
  private var aspectRatio: String?
104
105
  private var aspectMode: String = "contain"
105
106
  private var gridMode: String = "none"
@@ -1211,6 +1212,13 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1211
1212
  print("[CameraPreview] captureImage callback received")
1212
1213
  DispatchQueue.main.async {
1213
1214
  print("[CameraPreview] Processing capture on main thread")
1215
+ // Ensure heading updates are stopped on all exit paths (error, guard failure, or success)
1216
+ defer {
1217
+ if withExifLocation ?? false {
1218
+ self.locationManager?.stopUpdatingHeading()
1219
+ self.currentHeading = nil
1220
+ }
1221
+ }
1214
1222
  if let error = error {
1215
1223
  print("[CameraPreview] Capture error: \(error.localizedDescription)")
1216
1224
  call.reject(error.localizedDescription)
@@ -1222,6 +1230,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1222
1230
  from: image,
1223
1231
  quality: Int(quality),
1224
1232
  location: withExifLocation ? self.currentLocation : nil,
1233
+ heading: withExifLocation ? self.currentHeading : nil,
1225
1234
  originalPhotoData: originalPhotoData
1226
1235
  )
1227
1236
  else {
@@ -1358,7 +1367,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1358
1367
  }
1359
1368
  }
1360
1369
 
1361
- private func createImageDataWithExif(from image: UIImage, quality: Int, location: CLLocation?, originalPhotoData: Data?) -> Data? {
1370
+ private func createImageDataWithExif(from image: UIImage, quality: Int, location: CLLocation?, heading: CLHeading?, originalPhotoData: Data?) -> Data? {
1362
1371
  guard let jpegDataAtQuality = image.jpegData(compressionQuality: CGFloat(Double(quality) / 100.0)) else {
1363
1372
  return nil
1364
1373
  }
@@ -1387,7 +1396,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1387
1396
  formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
1388
1397
  formatter.timeZone = TimeZone(abbreviation: "UTC")
1389
1398
 
1390
- let gpsDict: [String: Any] = [
1399
+ var gpsDict: [String: Any] = [
1391
1400
  kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
1392
1401
  kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
1393
1402
  kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
@@ -1397,6 +1406,21 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1397
1406
  kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
1398
1407
  ]
1399
1408
 
1409
+ // Add image direction (compass heading) when available
1410
+ if let heading = heading {
1411
+ let directionDegrees: Double
1412
+ let directionRef: String
1413
+ if heading.trueHeading >= 0 {
1414
+ directionDegrees = heading.trueHeading
1415
+ directionRef = "T"
1416
+ } else {
1417
+ directionDegrees = heading.magneticHeading
1418
+ directionRef = "M"
1419
+ }
1420
+ gpsDict[kCGImagePropertyGPSImgDirection as String] = directionDegrees
1421
+ gpsDict[kCGImagePropertyGPSImgDirectionRef as String] = directionRef
1422
+ }
1423
+
1400
1424
  finalProperties[kCGImagePropertyGPSDictionary as String] = gpsDict
1401
1425
  }
1402
1426
 
@@ -1836,9 +1860,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1836
1860
 
1837
1861
  private func getCurrentLocation(completion: @escaping (CLLocation?) -> Void) {
1838
1862
  print("[CameraPreview] getCurrentLocation called")
1863
+ self.currentHeading = nil
1839
1864
  self.locationCompletion = completion
1840
1865
  self.locationManager?.startUpdatingLocation()
1841
1866
  print("[CameraPreview] Started updating location")
1867
+ if CLLocationManager.headingAvailable() {
1868
+ self.locationManager?.startUpdatingHeading()
1869
+ print("[CameraPreview] Started updating heading")
1870
+ }
1842
1871
  }
1843
1872
 
1844
1873
  private var locationCompletion: ((CLLocation?) -> Void)?
@@ -1856,6 +1885,13 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
1856
1885
  }
1857
1886
  }
1858
1887
 
1888
+ public func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
1889
+ print("[CameraPreview] locationManager didUpdateHeading: trueHeading=\(newHeading.trueHeading), magneticHeading=\(newHeading.magneticHeading), accuracy=\(newHeading.headingAccuracy)")
1890
+ if newHeading.headingAccuracy >= 0 {
1891
+ self.currentHeading = newHeading
1892
+ }
1893
+ }
1894
+
1859
1895
  private func saveImageDataToGallery(imageData: Data, completion: @escaping (Bool, Error?) -> Void) {
1860
1896
  // Check if NSPhotoLibraryUsageDescription is present in Info.plist
1861
1897
  guard Bundle.main.object(forInfoDictionaryKey: "NSPhotoLibraryUsageDescription") != nil else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/camera-preview",
3
- "version": "8.1.4",
3
+ "version": "8.2.0",
4
4
  "description": "Camera preview",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {