capacitor-camera-multicapture 0.12.2

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.
Files changed (76) hide show
  1. package/CameraMultiCapture.podspec +17 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +28 -0
  4. package/README.md +551 -0
  5. package/android/build.gradle +69 -0
  6. package/android/src/main/AndroidManifest.xml +6 -0
  7. package/android/src/main/java/dev/hemang/cameramulticapture/CameraConfig.java +45 -0
  8. package/android/src/main/java/dev/hemang/cameramulticapture/CameraConfigMapper.java +98 -0
  9. package/android/src/main/java/dev/hemang/cameramulticapture/CameraMultiCapture.java +10 -0
  10. package/android/src/main/java/dev/hemang/cameramulticapture/CameraMultiCapturePlugin.java +1340 -0
  11. package/android/src/main/java/dev/hemang/cameramulticapture/ExifWrapper.java +108 -0
  12. package/android/src/main/java/dev/hemang/cameramulticapture/GenericUploadWorker.java +173 -0
  13. package/android/src/main/java/dev/hemang/cameramulticapture/ImageUtils.java +215 -0
  14. package/android/src/main/java/dev/hemang/cameramulticapture/PluginVersion.java +6 -0
  15. package/android/src/main/java/dev/hemang/cameramulticapture/ThumbnailGenerator.java +48 -0
  16. package/android/src/main/res/.gitkeep +0 -0
  17. package/dist/esm/controllers/camera-controller.d.ts +127 -0
  18. package/dist/esm/controllers/camera-controller.js +388 -0
  19. package/dist/esm/controllers/camera-controller.js.map +1 -0
  20. package/dist/esm/controllers/gallery-controller.d.ts +66 -0
  21. package/dist/esm/controllers/gallery-controller.js +333 -0
  22. package/dist/esm/controllers/gallery-controller.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +372 -0
  24. package/dist/esm/definitions.js +6 -0
  25. package/dist/esm/definitions.js.map +1 -0
  26. package/dist/esm/index.d.ts +5 -0
  27. package/dist/esm/index.js +6 -0
  28. package/dist/esm/index.js.map +1 -0
  29. package/dist/esm/overlay-manager.d.ts +124 -0
  30. package/dist/esm/overlay-manager.js +732 -0
  31. package/dist/esm/overlay-manager.js.map +1 -0
  32. package/dist/esm/overlay.d.ts +2 -0
  33. package/dist/esm/overlay.js +39 -0
  34. package/dist/esm/overlay.js.map +1 -0
  35. package/dist/esm/registration.d.ts +5 -0
  36. package/dist/esm/registration.js +8 -0
  37. package/dist/esm/registration.js.map +1 -0
  38. package/dist/esm/types/ui-types.d.ts +78 -0
  39. package/dist/esm/types/ui-types.js +2 -0
  40. package/dist/esm/types/ui-types.js.map +1 -0
  41. package/dist/esm/ui/capture-gesture-handler.d.ts +15 -0
  42. package/dist/esm/ui/capture-gesture-handler.js +87 -0
  43. package/dist/esm/ui/capture-gesture-handler.js.map +1 -0
  44. package/dist/esm/ui/default-styles.d.ts +27 -0
  45. package/dist/esm/ui/default-styles.js +134 -0
  46. package/dist/esm/ui/default-styles.js.map +1 -0
  47. package/dist/esm/ui/image-editor.d.ts +10 -0
  48. package/dist/esm/ui/image-editor.js +103 -0
  49. package/dist/esm/ui/image-editor.js.map +1 -0
  50. package/dist/esm/ui/layout-manager.d.ts +42 -0
  51. package/dist/esm/ui/layout-manager.js +199 -0
  52. package/dist/esm/ui/layout-manager.js.map +1 -0
  53. package/dist/esm/ui/media-viewer.d.ts +2 -0
  54. package/dist/esm/ui/media-viewer.js +267 -0
  55. package/dist/esm/ui/media-viewer.js.map +1 -0
  56. package/dist/esm/ui/pinch-zoom-handler.d.ts +37 -0
  57. package/dist/esm/ui/pinch-zoom-handler.js +118 -0
  58. package/dist/esm/ui/pinch-zoom-handler.js.map +1 -0
  59. package/dist/esm/ui/ui-factory.d.ts +39 -0
  60. package/dist/esm/ui/ui-factory.js +272 -0
  61. package/dist/esm/ui/ui-factory.js.map +1 -0
  62. package/dist/esm/version.d.ts +1 -0
  63. package/dist/esm/version.js +3 -0
  64. package/dist/esm/version.js.map +1 -0
  65. package/dist/esm/web.d.ts +69 -0
  66. package/dist/esm/web.js +122 -0
  67. package/dist/esm/web.js.map +1 -0
  68. package/dist/plugin.cjs.js +6274 -0
  69. package/dist/plugin.cjs.js.map +1 -0
  70. package/dist/plugin.js +6276 -0
  71. package/dist/plugin.js.map +1 -0
  72. package/ios/Sources/CameraMultiCapturePlugin/CameraMultiCapture.swift +8 -0
  73. package/ios/Sources/CameraMultiCapturePlugin/CameraMultiCapturePlugin.swift +1551 -0
  74. package/ios/Sources/CameraMultiCapturePlugin/PluginVersion.swift +2 -0
  75. package/ios/Tests/CameraMultiCapturePluginTests/CameraMultiCapturePluginTests.swift +15 -0
  76. package/package.json +93 -0
@@ -0,0 +1,1551 @@
1
+ import AVFoundation
2
+ import CallKit
3
+ import Capacitor
4
+ import Foundation
5
+ import Photos
6
+ import BackgroundTasks
7
+
8
+ struct CameraConfig {
9
+ var x: CGFloat
10
+ var y: CGFloat
11
+ var width: CGFloat
12
+ var height: CGFloat
13
+ var cameraPosition: AVCaptureDevice.Position
14
+ var quality: AVCaptureSession.Preset
15
+ var zoom: CGFloat
16
+ var jpegQuality: CGFloat
17
+ var autoFocus: Bool
18
+ var orientation: AVCaptureVideoOrientation
19
+ var flashMode: AVCaptureDevice.FlashMode
20
+ }
21
+
22
+ @objc(CameraMultiCapturePlugin)
23
+ public class CameraMultiCapturePlugin: CAPPlugin, CAPBridgedPlugin {
24
+ public let identifier = "CameraMultiCapturePlugin"
25
+ public let jsName = "CameraMultiCapture"
26
+ public let pluginMethods: [CAPPluginMethod] = [
27
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "capture", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "startVideoRecording", returnType: CAPPluginReturnPromise),
31
+ CAPPluginMethod(name: "stopVideoRecording", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "setZoom", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "updatePreviewRect", returnType: CAPPluginReturnPromise),
35
+ CAPPluginMethod(name: "setFlash", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "getFlash", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "setTorch", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "getTorch", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "getAvailableZoomLevels", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "getAvailableCameras", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "switchToPhysicalCamera", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "queueBackgroundUpload", returnType: CAPPluginReturnPromise),
45
+ CAPPluginMethod(name: "getUploadStatus", returnType: CAPPluginReturnPromise),
46
+ CAPPluginMethod(name: "getVersion", returnType: CAPPluginReturnPromise),
47
+ ]
48
+
49
+ var captureSession: AVCaptureSession?
50
+ var currentInput: AVCaptureDeviceInput?
51
+ var audioInput: AVCaptureDeviceInput?
52
+ var photoOutput: AVCapturePhotoOutput?
53
+ var movieOutput: AVCaptureMovieFileOutput?
54
+ var previewLayer: AVCaptureVideoPreviewLayer?
55
+ var cameraPreviewView: UIView?
56
+ var cameraPosition: AVCaptureDevice.Position = .back
57
+ var currentFlashMode: AVCaptureDevice.FlashMode = .off
58
+ var currentOrientation: AVCaptureVideoOrientation = .portrait
59
+ let sessionQueue = DispatchQueue(label: "camera.session.queue")
60
+ var captureDelegate: PhotoCaptureDelegate?
61
+ var videoCaptureDelegate: VideoCaptureDelegate?
62
+ var pendingVideoStopCall: CAPPluginCall?
63
+ var autoStoppedVideoResult: [String: Any]?
64
+ var maxRecordingDurationSeconds: Double = 0
65
+ var enableSaving: Bool = false
66
+ var galleryAlbumName: String = "Camera"
67
+ var isUsingVirtualDevice: Bool = false
68
+ // Divisor to convert between API zoom factors and user-facing values.
69
+ // On triple-camera virtual devices (e.g. iPhone 15 Pro), the API scale
70
+ // starts at 1.0 for ultrawide, with switchover at [2, 6]. The first
71
+ // switchover factor (2) represents "1x" (wide), so we divide API values
72
+ // by 2 to get user-facing labels: 0.5x, 1x, 3x.
73
+ // On devices without ultrawide, this stays 1.0 (no conversion needed).
74
+ var zoomScaleDivisor: CGFloat = 1.0
75
+
76
+
77
+
78
+ private func detectCurrentOrientation() -> AVCaptureVideoOrientation {
79
+ switch UIDevice.current.orientation {
80
+ case .portrait:
81
+ return .portrait
82
+ case .portraitUpsideDown:
83
+ return .portraitUpsideDown
84
+ case .landscapeLeft:
85
+ return .landscapeRight
86
+ case .landscapeRight:
87
+ return .landscapeLeft
88
+ default:
89
+ if #available(iOS 13.0, *) {
90
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
91
+ switch windowScene.interfaceOrientation {
92
+ case .portrait:
93
+ return .portrait
94
+ case .portraitUpsideDown:
95
+ return .portraitUpsideDown
96
+ case .landscapeLeft:
97
+ return .landscapeRight
98
+ case .landscapeRight:
99
+ return .landscapeLeft
100
+ default:
101
+ return .portrait
102
+ }
103
+ }
104
+ }
105
+ return .portrait
106
+ }
107
+ }
108
+
109
+ @objc func start(_ call: CAPPluginCall) {
110
+ print("Received data from JS: \(call.dictionaryRepresentation)")
111
+
112
+ // Check camera permission before starting
113
+ let cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video)
114
+ if cameraAuthStatus != .authorized {
115
+ call.reject("Camera permission not granted. Please call requestPermissions() first.")
116
+ return
117
+ }
118
+
119
+ guard let previewRect = call.getObject("previewRect") else {
120
+ call.reject("Missing previewRect")
121
+ return
122
+ }
123
+
124
+ let x = CGFloat((previewRect["x"] as? NSNumber)?.floatValue ?? 0)
125
+ let y = CGFloat((previewRect["y"] as? NSNumber)?.floatValue ?? 0)
126
+ let width = CGFloat(
127
+ (previewRect["width"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.width))
128
+ let height = CGFloat(
129
+ (previewRect["height"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.height))
130
+
131
+ let position =
132
+ (call.getString("cameraPosition") == "front") ? AVCaptureDevice.Position.front : .back
133
+ let qualityName = call.getString("captureMode") ?? "high"
134
+ let qualityPreset: AVCaptureSession.Preset = (qualityName == "low") ? .medium : .high
135
+ let zoom = CGFloat(call.getFloat("zoom") ?? 1.0)
136
+ let jpegQuality = CGFloat(call.getFloat("jpegQuality") ?? 0.8)
137
+ let autofocus = call.getBool("autoFocus") ?? true
138
+ let maxRecordingDuration = Double(call.getFloat("maxRecordingDuration") ?? 0)
139
+
140
+ let orientation: AVCaptureVideoOrientation
141
+ if let rotation = call.getInt("rotation") {
142
+ switch rotation {
143
+ case 90: orientation = .landscapeRight
144
+ case 180: orientation = .portraitUpsideDown
145
+ case 270: orientation = .landscapeLeft
146
+ default: orientation = .portrait
147
+ }
148
+ } else {
149
+ orientation = detectCurrentOrientation()
150
+ }
151
+
152
+ let flashModeString = call.getString("flash") ?? "off"
153
+ let flashMode: AVCaptureDevice.FlashMode
154
+ switch flashModeString {
155
+ case "on": flashMode = .on
156
+ case "auto": flashMode = .auto
157
+ default: flashMode = .off
158
+ }
159
+
160
+ let config = CameraConfig(
161
+ x: x, y: y, width: width, height: height,
162
+ cameraPosition: position, quality: qualityPreset,
163
+ zoom: zoom, jpegQuality: jpegQuality, autoFocus: autofocus,
164
+ orientation: orientation, flashMode: flashMode
165
+ )
166
+ self.cameraPosition = config.cameraPosition
167
+ self.currentFlashMode = config.flashMode
168
+ self.currentOrientation = config.orientation
169
+ self.maxRecordingDurationSeconds = maxRecordingDuration > 0 ? maxRecordingDuration : 0
170
+ self.enableSaving = call.getBool("enableSaving") ?? false
171
+ self.galleryAlbumName = call.getString("galleryAlbumName") ?? "Camera"
172
+
173
+ self.sessionQueue.async {
174
+ do {
175
+ try self.configureSession(with: config, call: call)
176
+ DispatchQueue.main.async {
177
+ call.resolve()
178
+ }
179
+ } catch {
180
+ DispatchQueue.main.async {
181
+ call.reject(
182
+ "Camera configuration failed: \(error.localizedDescription)")
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ @objc func stop(_ call: CAPPluginCall) {
189
+ DispatchQueue.main.async {
190
+ if let device = self.currentInput?.device, device.hasTorch, device.torchMode != .off {
191
+ do {
192
+ try device.lockForConfiguration()
193
+ device.torchMode = .off
194
+ device.unlockForConfiguration()
195
+ } catch { /* best effort */ }
196
+ }
197
+
198
+ if self.movieOutput?.isRecording == true {
199
+ self.movieOutput?.stopRecording()
200
+ }
201
+ self.captureSession?.stopRunning()
202
+ self.previewLayer?.removeFromSuperlayer()
203
+ self.captureSession = nil
204
+ self.currentInput = nil
205
+ self.audioInput = nil
206
+ self.photoOutput = nil
207
+ self.movieOutput = nil
208
+ self.videoCaptureDelegate = nil
209
+ self.pendingVideoStopCall = nil
210
+ self.autoStoppedVideoResult = nil
211
+ self.isUsingVirtualDevice = false
212
+
213
+ // Restore webView background after camera preview is removed
214
+ if let webView = self.bridge?.webView {
215
+ webView.isOpaque = true
216
+ webView.backgroundColor = nil
217
+ webView.scrollView.backgroundColor = nil
218
+ webView.layer.backgroundColor = nil
219
+ webView.evaluateJavaScript("document.documentElement.style.backgroundColor = ''", completionHandler: nil)
220
+ }
221
+
222
+ call.resolve()
223
+ }
224
+ }
225
+
226
+ @objc func capture(_ call: CAPPluginCall) {
227
+ guard let session = captureSession, photoOutput != nil else {
228
+ call.reject("Camera not initialized")
229
+ return
230
+ }
231
+
232
+ if !session.isRunning {
233
+ DispatchQueue.global(qos: .userInitiated).async {
234
+ session.startRunning()
235
+ DispatchQueue.main.async {
236
+ self.performCapture(call)
237
+ }
238
+ }
239
+ return
240
+ }
241
+
242
+ performCapture(call)
243
+ }
244
+
245
+ private func performCapture(_ call: CAPPluginCall) {
246
+ guard let photoOutput = photoOutput else {
247
+ call.reject("Photo output not initialized")
248
+ return
249
+ }
250
+
251
+ // Turn off torch before capture when flash is enabled; the LED is shared
252
+ // hardware so an active torch prevents the flash from firing correctly.
253
+ if currentFlashMode != .off,
254
+ let device = currentInput?.device, device.hasTorch, device.torchMode == .on {
255
+ do {
256
+ try device.lockForConfiguration()
257
+ device.torchMode = .off
258
+ device.unlockForConfiguration()
259
+ } catch {
260
+ print("[CameraMultiCapture] Failed to turn off torch before flash capture: \(error)")
261
+ }
262
+ }
263
+
264
+ let resultType = call.getString("resultType") ?? "base64"
265
+ let settings = AVCapturePhotoSettings()
266
+ settings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled
267
+
268
+ // Apply flash settings
269
+ if photoOutput.supportedFlashModes.contains(currentFlashMode) {
270
+ settings.flashMode = currentFlashMode
271
+ }
272
+
273
+ // Set photo orientation to match current preview orientation
274
+ if let connection = photoOutput.connection(with: .video) {
275
+ connection.videoOrientation = currentOrientation
276
+ // Prevent front camera from capturing mirrored images
277
+ if self.cameraPosition == .front {
278
+ connection.isVideoMirrored = false
279
+ }
280
+ }
281
+
282
+ let delegate = PhotoCaptureDelegate(plugin: self, call: call, resultType: resultType, isFrontCamera: cameraPosition == .front, enableSaving: enableSaving, galleryAlbumName: galleryAlbumName)
283
+ self.captureDelegate = delegate
284
+
285
+ photoOutput.capturePhoto(with: settings, delegate: delegate)
286
+ }
287
+
288
+ @objc func startVideoRecording(_ call: CAPPluginCall) {
289
+ let audioAuthStatus = AVCaptureDevice.authorizationStatus(for: .audio)
290
+ if audioAuthStatus == .denied || audioAuthStatus == .restricted {
291
+ call.reject("Microphone permission denied. Please enable microphone access in Settings.")
292
+ return
293
+ }
294
+ if audioAuthStatus == .notDetermined {
295
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
296
+ DispatchQueue.main.async {
297
+ if granted {
298
+ self.startVideoRecordingInternal(call)
299
+ } else {
300
+ call.reject("Microphone permission denied.")
301
+ }
302
+ }
303
+ }
304
+ return
305
+ }
306
+
307
+ startVideoRecordingInternal(call)
308
+ }
309
+
310
+ // True while a phone/VoIP call currently owns the audio hardware.
311
+ private let callObserver = CXCallObserver()
312
+ private var isCallActive: Bool {
313
+ callObserver.calls.contains { !$0.hasEnded }
314
+ }
315
+
316
+ // Adds/removes the microphone lazily around video recording. Keeping the mic
317
+ // out of the session during photo capture / preview prevents iOS from
318
+ // interrupting (and freezing) the whole session when a phone call grabs the
319
+ // audio hardware. See configureSession for the full rationale.
320
+ private func addAudioInput() {
321
+ // During a call the audio hardware belongs to the call; adding an audio
322
+ // input would let iOS interrupt the whole session, freezing the preview and
323
+ // stalling the recording at 00:00. Record video-only in that case instead.
324
+ guard !isCallActive else {
325
+ print("[CameraMultiCapture] Active call detected — recording video without audio")
326
+ return
327
+ }
328
+ guard let session = captureSession, audioInput == nil,
329
+ let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
330
+ do {
331
+ let input = try AVCaptureDeviceInput(device: audioDevice)
332
+ session.beginConfiguration()
333
+ if session.canAddInput(input) {
334
+ session.addInput(input)
335
+ self.audioInput = input
336
+ }
337
+ session.commitConfiguration()
338
+ } catch {
339
+ print("[CameraMultiCapture] Audio input config error: \(error)")
340
+ }
341
+ }
342
+
343
+ private func removeAudioInput() {
344
+ guard let session = captureSession, let input = audioInput else { return }
345
+ session.beginConfiguration()
346
+ session.removeInput(input)
347
+ session.commitConfiguration()
348
+ self.audioInput = nil
349
+ }
350
+
351
+ private func startVideoRecordingInternal(_ call: CAPPluginCall) {
352
+ guard let movieOutput = movieOutput else {
353
+ call.reject("Video output not initialized")
354
+ return
355
+ }
356
+ guard !movieOutput.isRecording else {
357
+ call.reject("Video recording is already in progress")
358
+ return
359
+ }
360
+
361
+ addAudioInput()
362
+
363
+ currentOrientation = detectCurrentOrientation()
364
+ if let videoConnection = movieOutput.connection(with: .video) {
365
+ videoConnection.videoOrientation = currentOrientation
366
+ if cameraPosition == .front {
367
+ videoConnection.isVideoMirrored = false
368
+ }
369
+ }
370
+
371
+ if maxRecordingDurationSeconds > 0 {
372
+ movieOutput.maxRecordedDuration = CMTime(
373
+ seconds: maxRecordingDurationSeconds,
374
+ preferredTimescale: 600
375
+ )
376
+ } else {
377
+ movieOutput.maxRecordedDuration = .invalid
378
+ }
379
+
380
+ let tempDir = FileManager.default.temporaryDirectory
381
+ let fileName = UUID().uuidString + ".mp4"
382
+ let fileURL = tempDir.appendingPathComponent(fileName)
383
+
384
+ if currentFlashMode == .on, let device = currentInput?.device, device.hasTorch, device.isTorchAvailable {
385
+ do {
386
+ try device.lockForConfiguration()
387
+ device.torchMode = .on
388
+ device.unlockForConfiguration()
389
+ } catch {
390
+ print("[CameraMultiCapture] Failed to enable torch for video recording: \(error)")
391
+ }
392
+ }
393
+
394
+ let delegate = VideoCaptureDelegate { [weak self] outputURL, error in
395
+ self?.handleVideoRecordingFinished(outputURL: outputURL, error: error)
396
+ }
397
+ self.videoCaptureDelegate = delegate
398
+ _ = movieOutput.connection(with: .audio) // ensure audio connection is active before recording
399
+ movieOutput.startRecording(to: fileURL, recordingDelegate: delegate)
400
+ call.resolve()
401
+ }
402
+
403
+ @objc func stopVideoRecording(_ call: CAPPluginCall) {
404
+ // If recording was already auto-stopped (e.g. by maxRecordingDuration),
405
+ // return the buffered result immediately.
406
+ if let buffered = autoStoppedVideoResult {
407
+ autoStoppedVideoResult = nil
408
+ call.resolve(buffered)
409
+ return
410
+ }
411
+
412
+ guard let movieOutput = movieOutput else {
413
+ call.reject("Video output not initialized")
414
+ return
415
+ }
416
+ guard movieOutput.isRecording else {
417
+ call.reject("No active video recording to stop")
418
+ return
419
+ }
420
+
421
+ pendingVideoStopCall = call
422
+ movieOutput.stopRecording()
423
+ }
424
+
425
+ private func handleVideoRecordingFinished(outputURL: URL, error: Error?) {
426
+ // Release the mic as soon as recording ends so the preview session is no
427
+ // longer interruptible by an incoming phone call.
428
+ removeAudioInput()
429
+
430
+ if let device = currentInput?.device, device.hasTorch, device.torchMode != .off {
431
+ do {
432
+ try device.lockForConfiguration()
433
+ device.torchMode = .off
434
+ device.unlockForConfiguration()
435
+ } catch { /* best effort */ }
436
+ }
437
+
438
+ defer { videoCaptureDelegate = nil }
439
+
440
+ // When maxRecordedDuration is reached, AVFoundation reports
441
+ // AVError.maximumDurationReached. The video file is still valid,
442
+ // so we treat it as a successful recording.
443
+ if let error = error {
444
+ let nsError = error as NSError
445
+ let isDurationLimit = nsError.domain == AVFoundationErrorDomain
446
+ && nsError.code == AVError.maximumDurationReached.rawValue
447
+ if !isDurationLimit {
448
+ if let call = pendingVideoStopCall {
449
+ pendingVideoStopCall = nil
450
+ call.reject("Video recording failed: \(error.localizedDescription)")
451
+ }
452
+ return
453
+ }
454
+ }
455
+
456
+ let thumbnail = generateVideoThumbnail(from: outputURL, size: 200) ?? ""
457
+ let duration = getVideoDuration(from: outputURL)
458
+
459
+ if enableSaving {
460
+ saveVideoToGallery(fileURL: outputURL)
461
+ }
462
+
463
+ let result: [String: Any] = [
464
+ "value": [
465
+ "uri": outputURL.absoluteString,
466
+ "thumbnail": thumbnail,
467
+ "duration": duration
468
+ ]
469
+ ]
470
+
471
+ if let call = pendingVideoStopCall {
472
+ pendingVideoStopCall = nil
473
+ call.resolve(result)
474
+ } else {
475
+ // Recording was auto-stopped by maxRecordedDuration.
476
+ // Buffer the result for the next stopVideoRecording call.
477
+ autoStoppedVideoResult = result
478
+ }
479
+ }
480
+
481
+ private func saveVideoToGallery(fileURL: URL) {
482
+ PHPhotoLibrary.shared().performChanges({
483
+ PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
484
+ }) { success, error in
485
+ if let error = error {
486
+ print("[CameraMultiCapture] Failed to save video to gallery: \(error.localizedDescription)")
487
+ }
488
+ }
489
+ }
490
+
491
+ @objc func setZoom(_ call: CAPPluginCall) {
492
+ guard let input = currentInput else {
493
+ call.reject("Camera not initialized")
494
+ return
495
+ }
496
+ let userZoom = CGFloat(call.getFloat("zoom") ?? 1.0)
497
+ // Convert user-facing zoom value back to API scale
498
+ let zoomFactor = userZoom * zoomScaleDivisor
499
+ do {
500
+ try input.device.lockForConfiguration()
501
+ // Clamp zoom factor to valid range
502
+ let minZoom = input.device.minAvailableVideoZoomFactor
503
+ let maxZoom = input.device.activeFormat.videoMaxZoomFactor
504
+ input.device.videoZoomFactor = max(minZoom, min(zoomFactor, maxZoom))
505
+ input.device.unlockForConfiguration()
506
+ // Return the user-facing zoom value
507
+ call.resolve(["zoom": Float(input.device.videoZoomFactor / zoomScaleDivisor)])
508
+ } catch {
509
+ call.reject("Failed to set zoom: \(error.localizedDescription)")
510
+ }
511
+ }
512
+
513
+ @objc func getAvailableZoomLevels(_ call: CAPPluginCall) {
514
+ guard let input = currentInput else {
515
+ call.reject("Camera not initialized")
516
+ return
517
+ }
518
+
519
+ let device = input.device
520
+ // Convert API zoom range to user-facing values
521
+ let minZoom = device.minAvailableVideoZoomFactor / zoomScaleDivisor
522
+ let maxZoom = device.activeFormat.videoMaxZoomFactor / zoomScaleDivisor
523
+
524
+ // Generate suggested preset levels based on device capabilities
525
+ var presetLevels: [Float] = []
526
+
527
+ // Add ultra-wide if available (0.5x or 0.7x depending on device)
528
+ let userMin = Float(minZoom)
529
+ if userMin < 1.0 {
530
+ let ultraWide: Float
531
+ if userMin > 0.6 && userMin < 0.8 {
532
+ ultraWide = 0.7
533
+ } else if userMin < 0.6 {
534
+ ultraWide = 0.5
535
+ } else {
536
+ ultraWide = userMin
537
+ }
538
+ presetLevels.append(ultraWide)
539
+ }
540
+
541
+ // Always add 1x
542
+ presetLevels.append(1.0)
543
+
544
+ // Add telephoto presets based on max zoom
545
+ if maxZoom >= 2.0 {
546
+ presetLevels.append(2.0)
547
+ }
548
+ if maxZoom >= 3.0 {
549
+ presetLevels.append(3.0)
550
+ }
551
+ if maxZoom >= 5.0 {
552
+ presetLevels.append(5.0)
553
+ }
554
+ if maxZoom >= 10.0 {
555
+ presetLevels.append(10.0)
556
+ }
557
+
558
+ call.resolve([
559
+ "minZoom": Float(minZoom),
560
+ "maxZoom": Float(maxZoom),
561
+ "presetLevels": presetLevels
562
+ ])
563
+ }
564
+
565
+ @objc func switchCamera(_ call: CAPPluginCall) {
566
+ guard let session = captureSession,
567
+ let currentInput = currentInput
568
+ else {
569
+ call.reject("Camera not initialized")
570
+ return
571
+ }
572
+
573
+ if movieOutput?.isRecording == true {
574
+ call.reject("Cannot switch camera while recording")
575
+ return
576
+ }
577
+
578
+ // Turn off torch on the current device before switching cameras
579
+ let device = currentInput.device
580
+ if device.hasTorch && device.torchMode == .on {
581
+ do {
582
+ try device.lockForConfiguration()
583
+ device.torchMode = .off
584
+ device.unlockForConfiguration()
585
+ } catch {
586
+ // Ignore failure here; we are switching cameras anyway
587
+ }
588
+ }
589
+
590
+ session.beginConfiguration()
591
+ session.removeInput(currentInput)
592
+ self.cameraPosition = (self.cameraPosition == .back) ? .front : .back
593
+
594
+ do {
595
+ // Re-select a virtual device for the back camera to preserve
596
+ // seamless zoom across all physical lenses
597
+ var newDevice: AVCaptureDevice? = nil
598
+ if self.cameraPosition == .back {
599
+ let virtualDeviceTypes: [AVCaptureDevice.DeviceType] = [
600
+ .builtInTripleCamera,
601
+ .builtInDualWideCamera,
602
+ .builtInDualCamera
603
+ ]
604
+ for deviceType in virtualDeviceTypes {
605
+ if let device = AVCaptureDevice.default(deviceType, for: .video, position: .back) {
606
+ newDevice = device
607
+ self.isUsingVirtualDevice = true
608
+ // Recompute zoom scale divisor for the new device
609
+ let switchOvers = device.virtualDeviceSwitchOverVideoZoomFactors
610
+ if switchOvers.count > 0 && device.minAvailableVideoZoomFactor >= 1.0 {
611
+ self.zoomScaleDivisor = CGFloat(switchOvers[0].floatValue)
612
+ } else {
613
+ self.zoomScaleDivisor = 1.0
614
+ }
615
+ break
616
+ }
617
+ }
618
+ }
619
+
620
+ // Fallback to wide-angle (also used for front camera)
621
+ if newDevice == nil {
622
+ newDevice = AVCaptureDevice.default(
623
+ .builtInWideAngleCamera, for: .video, position: self.cameraPosition)
624
+ self.isUsingVirtualDevice = false
625
+ self.zoomScaleDivisor = 1.0
626
+ }
627
+
628
+ guard let device = newDevice else {
629
+ session.commitConfiguration()
630
+ call.reject("Desired camera not available")
631
+ return
632
+ }
633
+
634
+ let newInput = try AVCaptureDeviceInput(device: device)
635
+ if session.canAddInput(newInput) {
636
+ session.addInput(newInput)
637
+ self.currentInput = newInput
638
+ session.commitConfiguration()
639
+ call.resolve()
640
+ } else {
641
+ session.commitConfiguration()
642
+ call.reject("Cannot add new camera input")
643
+ }
644
+ } catch {
645
+ session.commitConfiguration()
646
+ call.reject("Switch camera error: \(error.localizedDescription)")
647
+ }
648
+ }
649
+
650
+ @objc func getAvailableCameras(_ call: CAPPluginCall) {
651
+ // Check for available cameras on the current position
652
+ let hasUltrawide = AVCaptureDevice.default(
653
+ .builtInUltraWideCamera, for: .video, position: cameraPosition
654
+ ) != nil
655
+
656
+ let hasWide = AVCaptureDevice.default(
657
+ .builtInWideAngleCamera, for: .video, position: cameraPosition
658
+ ) != nil
659
+
660
+ let hasTelephoto = AVCaptureDevice.default(
661
+ .builtInTelephotoCamera, for: .video, position: cameraPosition
662
+ ) != nil
663
+
664
+ var result = [String: Any]()
665
+ result["hasUltrawide"] = hasUltrawide
666
+ result["hasWide"] = hasWide
667
+ result["hasTelephoto"] = hasTelephoto
668
+
669
+ // Derive user-facing zoom factors from the virtual device's switch-over points.
670
+ // On triple-camera devices (e.g. iPhone 15 Pro), the API zoom scale is:
671
+ // 1.0 = ultrawide, switchOverFactors = [2, 6]
672
+ // meaning UW→W at API 2.0, W→Tele at API 6.0
673
+ // Apple's Camera app normalizes by dividing by the first switchover
674
+ // so users see 0.5x, 1x, 3x. We do the same.
675
+ if let device = currentInput?.device,
676
+ isUsingVirtualDevice {
677
+ let switchOverFactors = device.virtualDeviceSwitchOverVideoZoomFactors
678
+
679
+ // The first switchover factor is the API zoom value for the wide lens ("1x").
680
+ // We divide all API values by this to get user-facing labels.
681
+ if hasUltrawide && switchOverFactors.count > 0 {
682
+ let wideApiZoom = CGFloat(switchOverFactors[0].floatValue)
683
+ self.zoomScaleDivisor = wideApiZoom
684
+
685
+ let minApiZoom = device.minAvailableVideoZoomFactor
686
+ result["ultrawideZoomFactor"] = Float(minApiZoom / wideApiZoom)
687
+ } else if hasUltrawide {
688
+ self.zoomScaleDivisor = 1.0
689
+ result["ultrawideZoomFactor"] = 0.5
690
+ } else {
691
+ self.zoomScaleDivisor = 1.0
692
+ }
693
+
694
+ result["wideZoomFactor"] = 1.0
695
+
696
+ if hasTelephoto && switchOverFactors.count > 0 {
697
+ let teleApiZoom = CGFloat(switchOverFactors.last!.floatValue)
698
+ result["telephotoZoomFactor"] = Float(teleApiZoom / self.zoomScaleDivisor)
699
+ } else if hasTelephoto {
700
+ result["telephotoZoomFactor"] = 2.0
701
+ }
702
+ } else {
703
+ self.zoomScaleDivisor = 1.0
704
+ // Fallback for non-virtual device sessions
705
+ if hasUltrawide {
706
+ result["ultrawideZoomFactor"] = 0.5
707
+ }
708
+ result["wideZoomFactor"] = 1.0
709
+ if hasTelephoto {
710
+ result["telephotoZoomFactor"] = 2.0
711
+ }
712
+ }
713
+
714
+ call.resolve(result)
715
+ }
716
+
717
+ @objc func switchToPhysicalCamera(_ call: CAPPluginCall) {
718
+ guard let input = currentInput,
719
+ let userZoomFactor = call.getFloat("zoomFactor") else {
720
+ call.reject("Camera not initialized or missing zoomFactor parameter")
721
+ return
722
+ }
723
+
724
+ // When using a virtual device, simply set the zoom factor.
725
+ // iOS automatically activates the correct physical camera at the right
726
+ // switch-over points. No need to swap device inputs.
727
+ if isUsingVirtualDevice {
728
+ do {
729
+ let device = input.device
730
+ // Convert user-facing zoom value back to API scale
731
+ let apiZoom = CGFloat(userZoomFactor) * zoomScaleDivisor
732
+ try device.lockForConfiguration()
733
+ let clampedZoom = max(
734
+ device.minAvailableVideoZoomFactor,
735
+ min(apiZoom, device.activeFormat.videoMaxZoomFactor)
736
+ )
737
+ device.videoZoomFactor = clampedZoom
738
+ device.unlockForConfiguration()
739
+ // Return user-facing zoom value
740
+ call.resolve(["zoom": Float(clampedZoom / zoomScaleDivisor)])
741
+ } catch {
742
+ call.reject("Failed to set zoom: \(error.localizedDescription)")
743
+ }
744
+ return
745
+ }
746
+
747
+ if movieOutput?.isRecording == true {
748
+ call.reject("Cannot switch camera while recording")
749
+ return
750
+ }
751
+
752
+ // Fallback: manually switch physical cameras for non-virtual device sessions
753
+ guard let session = captureSession else {
754
+ call.reject("Camera session not available")
755
+ return
756
+ }
757
+
758
+ let deviceType: AVCaptureDevice.DeviceType
759
+ if userZoomFactor < 1.0 {
760
+ deviceType = .builtInUltraWideCamera
761
+ } else if userZoomFactor >= 2.0 {
762
+ deviceType = .builtInTelephotoCamera
763
+ } else {
764
+ deviceType = .builtInWideAngleCamera
765
+ }
766
+
767
+ guard let newDevice = AVCaptureDevice.default(
768
+ deviceType, for: .video, position: cameraPosition
769
+ ) else {
770
+ guard let fallbackDevice = AVCaptureDevice.default(
771
+ .builtInWideAngleCamera, for: .video, position: cameraPosition
772
+ ) else {
773
+ call.reject("No suitable camera available")
774
+ return
775
+ }
776
+ switchToDevice(fallbackDevice, in: session, call: call)
777
+ return
778
+ }
779
+
780
+ switchToDevice(newDevice, in: session, call: call)
781
+ }
782
+
783
+ private func switchToDevice(_ device: AVCaptureDevice, in session: AVCaptureSession, call: CAPPluginCall) {
784
+ session.beginConfiguration()
785
+
786
+ // Remove current input
787
+ if let currentInput = currentInput {
788
+ session.removeInput(currentInput)
789
+ }
790
+
791
+ do {
792
+ let newInput = try AVCaptureDeviceInput(device: device)
793
+ if session.canAddInput(newInput) {
794
+ session.addInput(newInput)
795
+ self.currentInput = newInput
796
+
797
+ session.commitConfiguration()
798
+ call.resolve()
799
+ } else {
800
+ session.commitConfiguration()
801
+ call.reject("Cannot add new camera input")
802
+ }
803
+ } catch {
804
+ session.commitConfiguration()
805
+ call.reject("Failed to switch camera: \(error.localizedDescription)")
806
+ }
807
+ }
808
+
809
+ @objc func updatePreviewRect(_ call: CAPPluginCall) {
810
+ guard let previewRect = call.getObject("previewRect") ?? (call.options as? JSObject) else {
811
+ call.reject("Missing previewRect")
812
+ return
813
+ }
814
+
815
+ let x = CGFloat((previewRect["x"] as? NSNumber)?.floatValue ?? 0)
816
+ let y = CGFloat((previewRect["y"] as? NSNumber)?.floatValue ?? 0)
817
+ let width = CGFloat(
818
+ (previewRect["width"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.width))
819
+ let height = CGFloat(
820
+ (previewRect["height"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.height))
821
+
822
+ DispatchQueue.main.async {
823
+ if let previewView = self.cameraPreviewView, let videoLayer = self.previewLayer {
824
+ previewView.frame = CGRect(x: x, y: y, width: width, height: height)
825
+ videoLayer.frame = previewView.bounds
826
+
827
+ if call.getInt("rotation") == nil {
828
+ let newOrientation = self.detectCurrentOrientation()
829
+ videoLayer.connection?.videoOrientation = newOrientation
830
+ self.currentOrientation = newOrientation
831
+ } else {
832
+ let rotation = call.getInt("rotation") ?? 0
833
+ let newOrientation: AVCaptureVideoOrientation
834
+ switch rotation {
835
+ case 90: newOrientation = .landscapeRight
836
+ case 180: newOrientation = .portraitUpsideDown
837
+ case 270: newOrientation = .landscapeLeft
838
+ default: newOrientation = .portrait
839
+ }
840
+
841
+ videoLayer.connection?.videoOrientation = newOrientation
842
+ self.currentOrientation = newOrientation
843
+ }
844
+
845
+ call.resolve()
846
+ } else {
847
+ call.reject("Preview view not initialized")
848
+ }
849
+ }
850
+ }
851
+
852
+ func configureSession(with config: CameraConfig, call: CAPPluginCall) throws {
853
+ let session = AVCaptureSession()
854
+ session.beginConfiguration()
855
+ session.sessionPreset = config.quality
856
+
857
+ // Prefer virtual multi-camera device for seamless zoom across all lenses.
858
+ // Virtual devices expose a single continuous zoom scale where iOS
859
+ // automatically switches physical cameras at the correct switch-over points.
860
+ let camera: AVCaptureDevice
861
+ let virtualDeviceTypes: [AVCaptureDevice.DeviceType] = [
862
+ .builtInTripleCamera,
863
+ .builtInDualWideCamera,
864
+ .builtInDualCamera
865
+ ]
866
+ var foundVirtualDevice: AVCaptureDevice? = nil
867
+ for deviceType in virtualDeviceTypes {
868
+ if let device = AVCaptureDevice.default(deviceType, for: .video, position: config.cameraPosition) {
869
+ foundVirtualDevice = device
870
+ break
871
+ }
872
+ }
873
+ if let virtualDevice = foundVirtualDevice {
874
+ camera = virtualDevice
875
+ self.isUsingVirtualDevice = true
876
+ // Compute zoom scale divisor from switchover factors
877
+ let switchOvers = virtualDevice.virtualDeviceSwitchOverVideoZoomFactors
878
+ if switchOvers.count > 0 && virtualDevice.minAvailableVideoZoomFactor >= 1.0 {
879
+ // First switchover = the "1x" wide lens in API zoom scale
880
+ self.zoomScaleDivisor = CGFloat(switchOvers[0].floatValue)
881
+ } else {
882
+ self.zoomScaleDivisor = 1.0
883
+ }
884
+ } else if let wideCamera = AVCaptureDevice.default(
885
+ .builtInWideAngleCamera, for: .video, position: config.cameraPosition) {
886
+ camera = wideCamera
887
+ self.isUsingVirtualDevice = false
888
+ } else {
889
+ throw NSError(
890
+ domain: "Camera", code: 0,
891
+ userInfo: [NSLocalizedDescriptionKey: "Camera not available"])
892
+ }
893
+
894
+ let input = try AVCaptureDeviceInput(device: camera)
895
+ if session.canAddInput(input) {
896
+ session.addInput(input)
897
+ self.currentInput = input
898
+ // Audio input is added lazily in startVideoRecordingInternal, not here.
899
+ // A session that holds the microphone gets fully interrupted by iOS when
900
+ // a phone call takes the audio hardware (InterruptionReason
901
+ // .audioDeviceInUseByAnotherClient), which freezes the video preview.
902
+ // Photo capture / preview never need audio, so we keep the mic out of the
903
+ // session except while recording video.
904
+ } else {
905
+ throw NSError(
906
+ domain: "Camera", code: 0,
907
+ userInfo: [NSLocalizedDescriptionKey: "Unable to add camera input"])
908
+ }
909
+
910
+ let photoOut = AVCapturePhotoOutput()
911
+ if session.canAddOutput(photoOut) {
912
+ session.addOutput(photoOut)
913
+ photoOut.isHighResolutionCaptureEnabled = true
914
+ self.photoOutput = photoOut
915
+ }
916
+
917
+ let movieOut = AVCaptureMovieFileOutput()
918
+ if session.canAddOutput(movieOut) {
919
+ session.addOutput(movieOut)
920
+ self.movieOutput = movieOut
921
+ }
922
+ session.commitConfiguration()
923
+ self.captureSession = session
924
+
925
+ do {
926
+ try camera.lockForConfiguration()
927
+ // Convert user-facing zoom to API scale
928
+ camera.videoZoomFactor = config.zoom * zoomScaleDivisor
929
+ if config.autoFocus && camera.isFocusModeSupported(.continuousAutoFocus) {
930
+ camera.focusMode = .continuousAutoFocus
931
+ } else if camera.isFocusModeSupported(.locked) {
932
+ camera.focusMode = .locked
933
+ }
934
+ camera.unlockForConfiguration()
935
+ } catch {
936
+ print("Focus/zoom config error: \(error)")
937
+ }
938
+
939
+ DispatchQueue.main.async {
940
+ guard let webView = self.bridge?.webView else {
941
+ return
942
+ }
943
+
944
+ let previewView = UIView(
945
+ frame: CGRect(x: 0, y: 0, width: config.width, height: config.height))
946
+
947
+ webView.isOpaque = false
948
+ webView.backgroundColor = UIColor.clear
949
+ webView.scrollView.backgroundColor = UIColor.clear
950
+ webView.layer.backgroundColor = UIColor.clear.cgColor
951
+
952
+ let javascript = "document.documentElement.style.backgroundColor = 'transparent'"
953
+ webView.evaluateJavaScript(javascript) { (_, error) in
954
+ if let error = error {
955
+ print("[CameraMultiCapture] JS evaluation error: \(error)")
956
+ }
957
+ }
958
+
959
+ // Add camera preview to webView's scrollView and send to back
960
+ // This ensures camera is behind the web content
961
+ webView.scrollView.addSubview(previewView)
962
+ webView.scrollView.sendSubviewToBack(previewView)
963
+
964
+ // Position the preview view correctly within the scrollView
965
+ previewView.frame = CGRect(x: config.x, y: config.y, width: config.width, height: config.height)
966
+
967
+ let videoLayer = AVCaptureVideoPreviewLayer(session: session)
968
+ videoLayer.frame = previewView.bounds
969
+ videoLayer.videoGravity = .resizeAspectFill
970
+ videoLayer.connection?.videoOrientation = config.orientation
971
+ previewView.layer.addSublayer(videoLayer)
972
+ self.previewLayer = videoLayer
973
+ self.cameraPreviewView = previewView
974
+
975
+ if let parentView = webView.superview {
976
+ parentView.bringSubviewToFront(webView)
977
+ }
978
+
979
+ DispatchQueue.global(qos: .userInitiated).async {
980
+ self.captureSession?.startRunning()
981
+ }
982
+ }
983
+ }
984
+
985
+ @objc public override func checkPermissions(_ call: CAPPluginCall) {
986
+ let cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video)
987
+ let photosAuthStatus = PHPhotoLibrary.authorizationStatus()
988
+ let audioAuthStatus = AVCaptureDevice.authorizationStatus(for: .audio)
989
+
990
+ let cameraPermission: String
991
+ switch cameraAuthStatus {
992
+ case .authorized:
993
+ cameraPermission = "granted"
994
+ case .denied, .restricted:
995
+ cameraPermission = "denied"
996
+ case .notDetermined:
997
+ cameraPermission = "prompt"
998
+ @unknown default:
999
+ cameraPermission = "prompt"
1000
+ }
1001
+
1002
+ let photosPermission: String
1003
+ switch photosAuthStatus {
1004
+ case .authorized, .limited:
1005
+ photosPermission = "granted"
1006
+ case .denied, .restricted:
1007
+ photosPermission = "denied"
1008
+ case .notDetermined:
1009
+ photosPermission = "prompt"
1010
+ @unknown default:
1011
+ photosPermission = "prompt"
1012
+ }
1013
+
1014
+ let audioPermission: String
1015
+ switch audioAuthStatus {
1016
+ case .authorized:
1017
+ audioPermission = "granted"
1018
+ case .denied, .restricted:
1019
+ audioPermission = "denied"
1020
+ case .notDetermined:
1021
+ audioPermission = "prompt"
1022
+ @unknown default:
1023
+ audioPermission = "prompt"
1024
+ }
1025
+
1026
+ call.resolve([
1027
+ "camera": cameraPermission,
1028
+ "photos": photosPermission,
1029
+ "audio": audioPermission
1030
+ ])
1031
+ }
1032
+
1033
+ @objc public override func requestPermissions(_ call: CAPPluginCall) {
1034
+ let group = DispatchGroup()
1035
+ var cameraResult = "prompt"
1036
+ var photosResult = "prompt"
1037
+ var audioResult = "prompt"
1038
+
1039
+ // Request camera permission
1040
+ group.enter()
1041
+ AVCaptureDevice.requestAccess(for: .video) { granted in
1042
+ cameraResult = granted ? "granted" : "denied"
1043
+ group.leave()
1044
+ }
1045
+
1046
+ // Request photos permission
1047
+ group.enter()
1048
+ PHPhotoLibrary.requestAuthorization { status in
1049
+ switch status {
1050
+ case .authorized, .limited:
1051
+ photosResult = "granted"
1052
+ case .denied, .restricted:
1053
+ photosResult = "denied"
1054
+ case .notDetermined:
1055
+ photosResult = "prompt"
1056
+ @unknown default:
1057
+ photosResult = "denied"
1058
+ }
1059
+ group.leave()
1060
+ }
1061
+
1062
+ // Request microphone permission
1063
+ group.enter()
1064
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
1065
+ audioResult = granted ? "granted" : "denied"
1066
+ group.leave()
1067
+ }
1068
+
1069
+ // Wait for both permissions and return result
1070
+ group.notify(queue: .main) {
1071
+ call.resolve([
1072
+ "camera": cameraResult,
1073
+ "photos": photosResult,
1074
+ "audio": audioResult
1075
+ ])
1076
+ }
1077
+ }
1078
+
1079
+ @objc func setFlash(_ call: CAPPluginCall) {
1080
+ guard let modeString = call.getString("flashMode") else {
1081
+ call.reject("Missing flash mode parameter")
1082
+ return
1083
+ }
1084
+
1085
+ let flashMode: AVCaptureDevice.FlashMode
1086
+ switch modeString {
1087
+ case "on": flashMode = .on
1088
+ case "auto": flashMode = .auto
1089
+ default: flashMode = .off
1090
+ }
1091
+
1092
+ // Check if the current device supports flash
1093
+ guard let device = currentInput?.device, device.hasFlash else {
1094
+ call.reject("Flash not available on current camera")
1095
+ return
1096
+ }
1097
+
1098
+ // Update the current flash mode
1099
+ currentFlashMode = flashMode
1100
+
1101
+ call.resolve(["flashMode": modeString])
1102
+ }
1103
+
1104
+ @objc func getFlash(_ call: CAPPluginCall) {
1105
+ let modeString: String
1106
+ switch currentFlashMode {
1107
+ case .on: modeString = "on"
1108
+ case .auto: modeString = "auto"
1109
+ default: modeString = "off"
1110
+ }
1111
+
1112
+ call.resolve(["flashMode": modeString])
1113
+ }
1114
+
1115
+ @objc func setTorch(_ call: CAPPluginCall) {
1116
+ guard let enabled = call.getBool("enabled") else {
1117
+ call.reject("Missing enabled parameter")
1118
+ return
1119
+ }
1120
+ guard let device = currentInput?.device, device.hasTorch, device.isTorchAvailable else {
1121
+ call.reject("Torch not available on current camera")
1122
+ return
1123
+ }
1124
+ do {
1125
+ try device.lockForConfiguration()
1126
+ device.torchMode = enabled ? .on : .off
1127
+ device.unlockForConfiguration()
1128
+ call.resolve()
1129
+ } catch {
1130
+ call.reject("Failed to set torch: \(error.localizedDescription)")
1131
+ }
1132
+ }
1133
+
1134
+ @objc func getTorch(_ call: CAPPluginCall) {
1135
+ guard let device = currentInput?.device, device.hasTorch else {
1136
+ call.resolve(["enabled": false])
1137
+ return
1138
+ }
1139
+ let enabled = device.torchMode == .on
1140
+ call.resolve(["enabled": enabled])
1141
+ }
1142
+
1143
+ @objc func queueBackgroundUpload(_ call: CAPPluginCall) {
1144
+ guard let imageUri = call.getString("imageUri"),
1145
+ let uploadEndpoint = call.getString("uploadEndpoint"),
1146
+ let headers = call.getObject("headers") else {
1147
+ call.reject("Missing required parameters")
1148
+ return
1149
+ }
1150
+
1151
+ let formData = call.getObject("formData") ?? [:]
1152
+ let method = call.getString("method") ?? "POST"
1153
+ let deleteAfterUpload = call.getBool("deleteAfterUpload") ?? true // Default: true
1154
+ let jobId = UUID().uuidString
1155
+
1156
+ // Generate unique filename from imageUri or timestamp
1157
+ let uniqueFileName = self.generateUniqueFileName(from: imageUri)
1158
+
1159
+ // Store upload job for background processing
1160
+ let uploadJob: [String: Any] = [
1161
+ "jobId": jobId,
1162
+ "imageUri": imageUri,
1163
+ "uploadEndpoint": uploadEndpoint,
1164
+ "headers": headers,
1165
+ "formData": formData,
1166
+ "method": method,
1167
+ "fileName": uniqueFileName,
1168
+ "deleteAfterUpload": deleteAfterUpload,
1169
+ "status": "pending",
1170
+ "createdAt": Date().timeIntervalSince1970
1171
+ ]
1172
+
1173
+ UserDefaults.standard.set(uploadJob, forKey: "uploadJob_\(jobId)")
1174
+
1175
+ // Schedule proper background task
1176
+ self.scheduleBackgroundUpload(jobId: jobId, uploadJob: uploadJob)
1177
+
1178
+ call.resolve(["jobId": jobId])
1179
+ }
1180
+
1181
+ private func generateUniqueFileName(from imageUri: String) -> String {
1182
+ // Extract filename from URI if possible
1183
+ if let url = URL(string: imageUri) {
1184
+ let pathExtension = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
1185
+ let baseName = url.deletingPathExtension().lastPathComponent
1186
+
1187
+ // If we have a meaningful filename, use it with timestamp
1188
+ if !baseName.isEmpty && baseName != "image" {
1189
+ return "\(baseName)_\(Int(Date().timeIntervalSince1970)).\(pathExtension)"
1190
+ }
1191
+ }
1192
+
1193
+ // Fallback to timestamp-based naming
1194
+ let timestamp = Int(Date().timeIntervalSince1970)
1195
+ let randomId = UUID().uuidString.prefix(8)
1196
+ return "photo_\(timestamp)_\(randomId).jpg"
1197
+ }
1198
+
1199
+ private func scheduleBackgroundUpload(jobId: String, uploadJob: [String: Any]) {
1200
+ // iOS 13+ only - use modern BGTaskScheduler
1201
+ if #available(iOS 13.0, *) {
1202
+ let request = BGProcessingTaskRequest(identifier: "com.cameramulticapture.upload")
1203
+ request.requiresNetworkConnectivity = true
1204
+ request.requiresExternalPower = false
1205
+
1206
+ do {
1207
+ try BGTaskScheduler.shared.submit(request)
1208
+ print("✅ Background upload task scheduled: \(jobId)")
1209
+
1210
+ // Start immediate upload attempt
1211
+ self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
1212
+ } catch {
1213
+ print("❌ Failed to schedule background task: \(error)")
1214
+ // Fallback to immediate upload
1215
+ self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
1216
+ }
1217
+ } else {
1218
+ // iOS 12 and below - not supported, but still attempt immediate upload
1219
+ print("⚠️ Background uploads require iOS 13+. Attempting immediate upload only.")
1220
+ self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
1221
+ }
1222
+ }
1223
+
1224
+ @objc func getUploadStatus(_ call: CAPPluginCall) {
1225
+ guard let jobId = call.getString("jobId") else {
1226
+ call.reject("Missing jobId parameter")
1227
+ return
1228
+ }
1229
+
1230
+ if let uploadJob = UserDefaults.standard.dictionary(forKey: "uploadJob_\(jobId)") {
1231
+ let status = uploadJob["status"] as? String ?? "failed"
1232
+ var result: [String: Any] = ["status": status]
1233
+
1234
+ if let error = uploadJob["error"] as? String {
1235
+ result["error"] = error
1236
+ }
1237
+
1238
+ call.resolve(result)
1239
+ } else {
1240
+ call.resolve(["status": "failed", "error": "Job not found"])
1241
+ }
1242
+ }
1243
+
1244
+ @objc func getVersion(_ call: CAPPluginCall) {
1245
+ call.resolve(["version": pluginVersion])
1246
+ }
1247
+
1248
+ private func performHttpUpload(jobId: String, uploadJob: [String: Any]) {
1249
+ guard let imageUri = uploadJob["imageUri"] as? String,
1250
+ let uploadEndpoint = uploadJob["uploadEndpoint"] as? String,
1251
+ let headers = uploadJob["headers"] as? [String: Any],
1252
+ let method = uploadJob["method"] as? String else {
1253
+ updateJobStatus(jobId: jobId, status: "failed", error: "Invalid job data")
1254
+ return
1255
+ }
1256
+
1257
+ updateJobStatus(jobId: jobId, status: "uploading")
1258
+
1259
+ guard let url = URL(string: uploadEndpoint),
1260
+ let imageUrl = URL(string: imageUri) else {
1261
+ updateJobStatus(jobId: jobId, status: "failed", error: "Invalid URLs")
1262
+ return
1263
+ }
1264
+
1265
+ // 🚀 Move file I/O to background thread to prevent UI blocking
1266
+ DispatchQueue.global(qos: .background).async {
1267
+ do {
1268
+ let imageData = try Data(contentsOf: imageUrl)
1269
+
1270
+ var request = URLRequest(url: url)
1271
+ request.httpMethod = method
1272
+ request.timeoutInterval = 60
1273
+
1274
+ // Add headers
1275
+ for (key, value) in headers {
1276
+ if let stringValue = value as? String {
1277
+ request.setValue(stringValue, forHTTPHeaderField: key)
1278
+ }
1279
+ }
1280
+
1281
+ // Set body based on method
1282
+ if method.uppercased() == "PUT" {
1283
+ // For PUT requests (Azure, S3), send raw image data
1284
+ request.httpBody = imageData
1285
+ } else {
1286
+ // For POST requests, create multipart form data
1287
+ let boundary = UUID().uuidString
1288
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
1289
+
1290
+ var body = Data()
1291
+
1292
+ // Add form data fields
1293
+ if let formData = uploadJob["formData"] as? [String: Any] {
1294
+ for (key, value) in formData {
1295
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
1296
+ body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
1297
+ body.append("\(value)\r\n".data(using: .utf8)!)
1298
+ }
1299
+ }
1300
+
1301
+ // Add image file with unique filename
1302
+ let fileName = uploadJob["fileName"] as? String ?? "photo_\(Int(Date().timeIntervalSince1970)).jpg"
1303
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
1304
+ body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
1305
+ body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
1306
+ body.append(imageData)
1307
+ body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
1308
+
1309
+ request.httpBody = body
1310
+ }
1311
+
1312
+ // Perform upload
1313
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
1314
+ if let error = error {
1315
+ self.updateJobStatus(jobId: jobId, status: "failed", error: error.localizedDescription)
1316
+ return
1317
+ }
1318
+
1319
+ if let httpResponse = response as? HTTPURLResponse {
1320
+ if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 {
1321
+ self.updateJobStatus(jobId: jobId, status: "completed")
1322
+
1323
+ // 🚀 Clean up file after successful upload (if enabled)
1324
+ if let deleteAfterUpload = uploadJob["deleteAfterUpload"] as? Bool, deleteAfterUpload,
1325
+ let imageUri = uploadJob["imageUri"] as? String,
1326
+ let imageUrl = URL(string: imageUri) {
1327
+ do {
1328
+ try FileManager.default.removeItem(at: imageUrl)
1329
+ print("✅ Cleaned up file after successful upload: \(imageUri)")
1330
+ } catch {
1331
+ print("⚠️ Failed to clean up file: \(error.localizedDescription)")
1332
+ }
1333
+ }
1334
+
1335
+ // Clean up completed job after some time
1336
+ DispatchQueue.main.asyncAfter(deadline: .now() + 300) { // 5 minutes
1337
+ UserDefaults.standard.removeObject(forKey: "uploadJob_\(jobId)")
1338
+ }
1339
+ } else {
1340
+ self.updateJobStatus(jobId: jobId, status: "failed", error: "HTTP \(httpResponse.statusCode)")
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ task.resume()
1346
+
1347
+ } catch {
1348
+ self.updateJobStatus(jobId: jobId, status: "failed", error: error.localizedDescription)
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ private func updateJobStatus(jobId: String, status: String, error: String? = nil) {
1354
+ var uploadJob = UserDefaults.standard.dictionary(forKey: "uploadJob_\(jobId)") ?? [:]
1355
+ uploadJob["status"] = status
1356
+ if let error = error {
1357
+ uploadJob["error"] = error
1358
+ }
1359
+ UserDefaults.standard.set(uploadJob, forKey: "uploadJob_\(jobId)")
1360
+ }
1361
+
1362
+ // MARK: - Thumbnail Generation
1363
+
1364
+ /**
1365
+ * Generate thumbnail using native iOS APIs
1366
+ * Uses UIImage scaling with UIGraphicsImageRenderer for optimal performance
1367
+ */
1368
+ func generateThumbnail(from imageData: Data, size: CGFloat) -> String? {
1369
+ guard let originalImage = UIImage(data: imageData) else {
1370
+ return nil
1371
+ }
1372
+
1373
+ // Calculate thumbnail size maintaining aspect ratio (center crop)
1374
+ let thumbnailSize = CGSize(width: size, height: size)
1375
+
1376
+ // Use UIGraphicsImageRenderer for efficient thumbnail generation
1377
+ let renderer = UIGraphicsImageRenderer(size: thumbnailSize)
1378
+
1379
+ let thumbnail = renderer.image { context in
1380
+ // Calculate scaling and cropping
1381
+ let originalSize = originalImage.size
1382
+ let scale = max(thumbnailSize.width / originalSize.width, thumbnailSize.height / originalSize.height)
1383
+
1384
+ let scaledSize = CGSize(
1385
+ width: originalSize.width * scale,
1386
+ height: originalSize.height * scale
1387
+ )
1388
+
1389
+ let drawRect = CGRect(
1390
+ x: (thumbnailSize.width - scaledSize.width) / 2,
1391
+ y: (thumbnailSize.height - scaledSize.height) / 2,
1392
+ width: scaledSize.width,
1393
+ height: scaledSize.height
1394
+ )
1395
+
1396
+ originalImage.draw(in: drawRect)
1397
+ }
1398
+
1399
+ // Convert to JPEG data with compression
1400
+ guard let thumbnailData = thumbnail.jpegData(compressionQuality: 0.85) else {
1401
+ return nil
1402
+ }
1403
+
1404
+ // Convert to Base64 data URI
1405
+ let base64String = thumbnailData.base64EncodedString()
1406
+ return "data:image/jpeg;base64,\(base64String)"
1407
+ }
1408
+
1409
+ /**
1410
+ * Generate thumbnail image from a video file.
1411
+ */
1412
+ func generateVideoThumbnail(from videoURL: URL, size: CGFloat) -> String? {
1413
+ let asset = AVAsset(url: videoURL)
1414
+ let imageGenerator = AVAssetImageGenerator(asset: asset)
1415
+ imageGenerator.appliesPreferredTrackTransform = true
1416
+ imageGenerator.maximumSize = CGSize(width: size, height: size)
1417
+
1418
+ do {
1419
+ let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil)
1420
+ let image = UIImage(cgImage: cgImage)
1421
+ guard let jpegData = image.jpegData(compressionQuality: 0.85) else {
1422
+ return nil
1423
+ }
1424
+ return "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
1425
+ } catch {
1426
+ return nil
1427
+ }
1428
+ }
1429
+
1430
+ /**
1431
+ * Get video duration in seconds.
1432
+ */
1433
+ func getVideoDuration(from videoURL: URL) -> Double {
1434
+ let asset = AVAsset(url: videoURL)
1435
+ let duration = asset.duration
1436
+ if duration.isValid && duration.seconds.isFinite {
1437
+ return duration.seconds
1438
+ }
1439
+ return 0
1440
+ }
1441
+ }
1442
+
1443
+ class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
1444
+ private let completion: (URL, Error?) -> Void
1445
+
1446
+ init(completion: @escaping (URL, Error?) -> Void) {
1447
+ self.completion = completion
1448
+ }
1449
+
1450
+ func fileOutput(
1451
+ _ output: AVCaptureFileOutput,
1452
+ didFinishRecordingTo outputFileURL: URL,
1453
+ from connections: [AVCaptureConnection],
1454
+ error: Error?
1455
+ ) {
1456
+ completion(outputFileURL, error)
1457
+ }
1458
+ }
1459
+
1460
+ class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
1461
+ weak var plugin: CameraMultiCapturePlugin?
1462
+ var call: CAPPluginCall
1463
+ var resultType: String
1464
+ var isFrontCamera: Bool
1465
+ var enableSaving: Bool
1466
+ var galleryAlbumName: String
1467
+
1468
+ init(plugin: CameraMultiCapturePlugin, call: CAPPluginCall, resultType: String, isFrontCamera: Bool, enableSaving: Bool, galleryAlbumName: String) {
1469
+ self.plugin = plugin
1470
+ self.call = call
1471
+ self.resultType = resultType
1472
+ self.isFrontCamera = isFrontCamera
1473
+ self.enableSaving = enableSaving
1474
+ self.galleryAlbumName = galleryAlbumName
1475
+ }
1476
+
1477
+ func photoOutput(
1478
+ _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto,
1479
+ error: Error?
1480
+ ) {
1481
+ if let error = error {
1482
+ call.reject("Photo capture error: \(error.localizedDescription)")
1483
+ return
1484
+ }
1485
+
1486
+ guard let data = photo.fileDataRepresentation() else {
1487
+ call.reject("No image data")
1488
+ return
1489
+ }
1490
+
1491
+ DispatchQueue.global(qos: .userInitiated).async {
1492
+ let tempDir = FileManager.default.temporaryDirectory
1493
+ let fileName = UUID().uuidString + ".jpg"
1494
+ let fileURL = tempDir.appendingPathComponent(fileName)
1495
+
1496
+ do {
1497
+ try data.write(to: fileURL)
1498
+
1499
+ if self.enableSaving {
1500
+ self.saveImageToGallery(fileURL: fileURL)
1501
+ }
1502
+
1503
+ var imageData = [String: String]()
1504
+ imageData["uri"] = fileURL.absoluteString
1505
+
1506
+ if let thumbnailDataUri = self.plugin?.generateThumbnail(from: data, size: 200) {
1507
+ imageData["thumbnail"] = thumbnailDataUri
1508
+ } else {
1509
+ imageData["thumbnail"] = ""
1510
+ }
1511
+
1512
+ DispatchQueue.main.async {
1513
+ self.call.resolve(["value": imageData])
1514
+ }
1515
+ } catch {
1516
+ DispatchQueue.main.async {
1517
+ self.call.reject("Failed to process image: \(error.localizedDescription)")
1518
+ }
1519
+ }
1520
+ }
1521
+ }
1522
+
1523
+ private func saveImageToGallery(fileURL: URL) {
1524
+ PHPhotoLibrary.shared().performChanges({
1525
+ PHAssetCreationRequest.forAsset().addResource(with: .photo, fileURL: fileURL, options: nil)
1526
+ }) { success, error in
1527
+ if let error = error {
1528
+ print("[CameraMultiCapture] Failed to save photo to gallery: \(error.localizedDescription)")
1529
+ }
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ extension UIImage {
1535
+ func mirrorHorizontally() -> UIImage {
1536
+ UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale)
1537
+ guard let context = UIGraphicsGetCurrentContext() else {
1538
+ return self
1539
+ }
1540
+
1541
+ context.translateBy(x: self.size.width, y: 0)
1542
+ context.scaleBy(x: -1.0, y: 1.0)
1543
+
1544
+ self.draw(in: CGRect(origin: .zero, size: self.size))
1545
+
1546
+ let mirroredImage = UIGraphicsGetImageFromCurrentImageContext()
1547
+ UIGraphicsEndImageContext()
1548
+
1549
+ return mirroredImage ?? self
1550
+ }
1551
+ }