@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 (
|
|
2255
|
-
FocusMeteringAction action = new FocusMeteringAction.Builder(
|
|
2256
|
-
|
|
2257
|
-
.
|
|
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.
|
|
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
|
-
|
|
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 {
|