capacitor-camera-view 2.0.1 → 2.1.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.
Files changed (28) hide show
  1. package/README.md +19 -9
  2. package/android/build.gradle +8 -5
  3. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +217 -126
  4. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +70 -30
  5. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraResult.kt +47 -0
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +11 -1
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +94 -5
  8. package/dist/docs.json +81 -0
  9. package/dist/esm/definitions.d.ts +44 -0
  10. package/dist/esm/definitions.js.map +1 -1
  11. package/dist/esm/web.d.ts +7 -1
  12. package/dist/esm/web.js +67 -2
  13. package/dist/esm/web.js.map +1 -1
  14. package/dist/plugin.cjs.js +68 -2
  15. package/dist/plugin.cjs.js.map +1 -1
  16. package/dist/plugin.js +68 -2
  17. package/dist/plugin.js.map +1 -1
  18. package/ios/Sources/CameraViewPlugin/CameraError.swift +97 -2
  19. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +109 -0
  20. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -2
  21. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +30 -41
  22. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +45 -13
  23. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +4 -3
  24. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +193 -59
  25. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +83 -84
  26. package/ios/Sources/CameraViewPlugin/TempFileManager.swift +181 -0
  27. package/ios/Sources/CameraViewPlugin/Utils.swift +102 -0
  28. package/package.json +17 -17
@@ -5,24 +5,24 @@ import Foundation
5
5
  /// Please read the Capacitor iOS Plugin Development Guide
6
6
  /// here: https://capacitorjs.com/docs/plugins/ios
7
7
  @objc(CameraViewPlugin)
8
- public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
8
+ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin, CameraEventDelegate {
9
9
  public let identifier = "CameraViewPlugin"
10
10
  public let jsName = "CameraView"
11
-
11
+
12
12
  /// Maps string flash mode values to AVCaptureDevice.FlashMode enum values.
13
13
  private let strToFlashModeMap: [String: AVCaptureDevice.FlashMode] = [
14
14
  "off": .off,
15
15
  "on": .on,
16
16
  "auto": .auto
17
17
  ]
18
-
18
+
19
19
  /// Maps AVCaptureDevice.FlashMode enum values to string values.
20
20
  private let flashModeToStrMap: [AVCaptureDevice.FlashMode: String] = [
21
21
  .off: "off",
22
22
  .on: "on",
23
23
  .auto: "auto"
24
24
  ]
25
-
25
+
26
26
  public let pluginMethods: [CAPPluginMethod] = [
27
27
  CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
28
28
  CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
@@ -42,45 +42,29 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
42
42
  CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
43
43
  CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise)
44
44
  ]
45
-
45
+
46
46
  private let implementation = CameraViewManager()
47
- private var notificationObserver: NSObjectProtocol?
48
-
47
+
49
48
  override public func load() {
50
- // Add observer for barcode detection events
51
- notificationObserver = NotificationCenter.default.addObserver(
52
- forName: Notification.Name("barcodeDetected"),
53
- object: nil,
54
- queue: .main
55
- ) { [weak self] notification in
56
- guard let self = self,
57
- let barcodeData = notification.userInfo as? [String: Any] else {
58
- return
59
- }
60
-
61
- // Emit event to JS
62
- self.notifyListeners("barcodeDetected", data: barcodeData)
63
- }
49
+ implementation.eventEmitter.delegate = self
64
50
  }
65
-
66
- deinit {
67
- if let observer = notificationObserver {
68
- NotificationCenter.default.removeObserver(observer)
69
- }
51
+
52
+ public func cameraDidDetectBarcode(_ event: BarcodeDetectedEvent) {
53
+ notifyListeners("barcodeDetected", data: event.toDictionary())
70
54
  }
71
-
55
+
72
56
  @objc func start(_ call: CAPPluginCall) {
73
57
  guard let webView = self.webView else {
74
58
  call.reject("Cannot find web view")
75
59
  return
76
60
  }
77
-
61
+
78
62
  maybeRequestCameraAccess { [weak self] granted in
79
63
  guard granted else {
80
64
  call.reject("Camera access denied")
81
65
  return
82
66
  }
83
-
67
+
84
68
  self?.implementation.startSession(
85
69
  configuration: sessionConfigFromPluginCall(call),
86
70
  webView: webView,
@@ -93,55 +77,69 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
93
77
  })
94
78
  }
95
79
  }
96
-
80
+
97
81
  @objc func stop(_ call: CAPPluginCall) {
98
82
  implementation.stopSession {
99
83
  call.resolve()
100
84
  }
101
85
  }
102
-
86
+
103
87
  @objc func isRunning(_ call: CAPPluginCall) {
104
88
  call.resolve([
105
89
  "isRunning": implementation.isRunning()
106
90
  ])
107
91
  }
108
-
92
+
109
93
  @objc func capture(_ call: CAPPluginCall) {
110
94
  let quality = call.getDouble("quality", 90.0)
111
95
  let saveToFile = call.getBool("saveToFile", false)
112
-
96
+
113
97
  guard quality >= 0.0 && quality <= 100.0 else {
114
98
  call.reject("Quality must be between 0 and 100")
115
99
  return
116
100
  }
117
-
118
- implementation.capturePhoto(completion: { (image, error) in
101
+
102
+ // Use optimized Data-based capture to avoid double JPEG encoding
103
+ implementation.capturePhotoData(completion: { [weak self] (data, error) in
119
104
  if let error = error {
120
105
  call.reject("Failed to capture image", nil, error)
121
106
  return
122
107
  }
123
-
124
- guard let image = image else {
108
+
109
+ guard let originalData = data else {
125
110
  call.reject("No image data")
126
111
  return
127
112
  }
128
-
129
- guard let imageData = image.jpegData(compressionQuality: quality / 100.0) else {
130
- call.reject("Failed to compress image")
131
- return
113
+
114
+ // Determine final image data based on quality setting
115
+ // For quality >= 90%, use original camera JPEG data to avoid re-encoding
116
+ // For lower quality, re-encode to reduce file size
117
+ let imageData: Data
118
+ if quality >= 90.0 {
119
+ // Use original JPEG data from camera (avoids quality loss and CPU overhead)
120
+ imageData = originalData
121
+ } else {
122
+ // Re-encode at lower quality for smaller file size
123
+ guard let image = UIImage(data: originalData),
124
+ let compressedData = image.jpegData(compressionQuality: quality / 100.0) else {
125
+ call.reject("Failed to compress image")
126
+ return
127
+ }
128
+ imageData = compressedData
132
129
  }
133
-
130
+
134
131
  if saveToFile {
132
+ // Use TempFileManager for tracked temp files with automatic cleanup
133
+ let tempFileURL = TempFileManager.shared.createTempImageFile()
135
134
  do {
136
- let tempFileURL = try createTempImageFile()
137
135
  try imageData.write(to: tempFileURL)
138
-
136
+
139
137
  // Convert file URL to webView-accessible path using Capacitor bridge
140
- guard let webPath = self.bridge?.portablePath(fromLocalURL: tempFileURL)?.absoluteString else {
138
+ guard let webPath = self?.bridge?.portablePath(fromLocalURL: tempFileURL)?.absoluteString else {
141
139
  call.reject("Failed to create web-accessible path")
142
140
  return
143
141
  }
144
-
142
+
145
143
  call.resolve(["webPath": webPath])
146
144
  } catch {
147
145
  call.reject("Failed to save image to file", nil, error)
@@ -154,43 +152,44 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
154
152
  }
155
153
  })
156
154
  }
157
-
155
+
158
156
  @objc func captureSample(_ call: CAPPluginCall) {
159
157
  let quality = call.getDouble("quality", 90.0)
160
158
  let saveToFile = call.getBool("saveToFile", false)
161
-
159
+
162
160
  guard quality >= 0.0 && quality <= 100.0 else {
163
161
  call.reject("Quality must be between 0 and 100")
164
162
  return
165
163
  }
166
-
167
- implementation.captureSnapshot { (image, error) in
164
+
165
+ implementation.captureSnapshot { [weak self] (image, error) in
168
166
  if let error = error {
169
167
  call.reject("Failed to capture frame", nil, error)
170
168
  return
171
169
  }
172
-
170
+
173
171
  guard let image = image else {
174
172
  call.reject("No frame data")
175
173
  return
176
174
  }
177
-
175
+
178
176
  guard let imageData = image.jpegData(compressionQuality: quality / 100.0) else {
179
177
  call.reject("Failed to compress image")
180
178
  return
181
179
  }
182
-
180
+
183
181
  if saveToFile {
182
+ // Use TempFileManager for tracked temp files with automatic cleanup
183
+ let tempFileURL = TempFileManager.shared.createTempImageFile()
184
184
  do {
185
- let tempFileURL = try createTempImageFile()
186
185
  try imageData.write(to: tempFileURL)
187
-
186
+
188
187
  // Convert file URL to webView-accessible path using Capacitor bridge
189
- guard let webPath = self.bridge?.portablePath(fromLocalURL: tempFileURL)?.absoluteString else {
188
+ guard let webPath = self?.bridge?.portablePath(fromLocalURL: tempFileURL)?.absoluteString else {
190
189
  call.reject("Failed to create web-accessible path")
191
190
  return
192
191
  }
193
-
192
+
194
193
  call.resolve(["webPath": webPath])
195
194
  } catch {
196
195
  call.reject("Failed to save sample to file", nil, error)
@@ -203,10 +202,10 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
203
202
  }
204
203
  }
205
204
  }
206
-
205
+
207
206
  @objc func getAvailableDevices(_ call: CAPPluginCall) {
208
207
  let devices = implementation.getAvailableDevices()
209
-
208
+
210
209
  var result = JSArray()
211
210
  for device in devices {
212
211
  var deviceInfo = JSObject()
@@ -216,12 +215,12 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
216
215
  deviceInfo["deviceType"] = convertToStringCameraType(device.deviceType)
217
216
  result.append(deviceInfo)
218
217
  }
219
-
218
+
220
219
  call.resolve([
221
220
  "devices": result
222
221
  ])
223
222
  }
224
-
223
+
225
224
  @objc func flipCamera(_ call: CAPPluginCall) {
226
225
  do {
227
226
  try implementation.flipCamera()
@@ -231,25 +230,25 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
231
230
  return
232
231
  }
233
232
  }
234
-
233
+
235
234
  @objc func getZoom(_ call: CAPPluginCall) {
236
235
  let zoom = implementation.getSupportedZoomFactors()
237
-
236
+
238
237
  call.resolve([
239
238
  "min": zoom.min,
240
239
  "max": zoom.max,
241
240
  "current": zoom.current
242
241
  ])
243
242
  }
244
-
243
+
245
244
  @objc func setZoom(_ call: CAPPluginCall) {
246
245
  guard let level = call.getDouble("level") else {
247
246
  call.reject("Zoom level must be provided")
248
247
  return
249
248
  }
250
-
249
+
251
250
  let ramp = call.getBool("ramp") ?? false
252
-
251
+
253
252
  do {
254
253
  try implementation.setZoomFactor(level, ramp: ramp)
255
254
  call.resolve()
@@ -258,35 +257,35 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
258
257
  return
259
258
  }
260
259
  }
261
-
260
+
262
261
  @objc func getFlashMode(_ call: CAPPluginCall) {
263
262
  let flashMode = implementation.getFlashMode()
264
-
263
+
265
264
  call.resolve([
266
265
  "flashMode": flashModeToStrMap[flashMode] ?? "off"
267
266
  ])
268
267
  }
269
-
268
+
270
269
  @objc func getSupportedFlashModes(_ call: CAPPluginCall) {
271
270
  let supportedFlashModes = implementation.getSupportedFlashModes()
272
271
  let supportedFlashModeStrArr = supportedFlashModes.map { flashModeToStrMap[$0] }
273
-
272
+
274
273
  call.resolve([
275
274
  "flashModes": supportedFlashModeStrArr
276
275
  ])
277
276
  }
278
-
277
+
279
278
  @objc func setFlashMode(_ call: CAPPluginCall) {
280
279
  guard let mode = call.getString("mode") else {
281
280
  call.reject("Flash mode must be provided")
282
281
  return
283
282
  }
284
-
283
+
285
284
  guard let flashMode = strToFlashModeMap[mode] else {
286
285
  call.reject("Invalid flash mode")
287
286
  return
288
287
  }
289
-
288
+
290
289
  do {
291
290
  try implementation.setFlashMode(flashMode)
292
291
  call.resolve()
@@ -294,14 +293,14 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
294
293
  call.reject("Failed to set flash mode", nil, error)
295
294
  }
296
295
  }
297
-
296
+
298
297
  @objc func isTorchAvailable(_ call: CAPPluginCall) {
299
298
  let available = implementation.isTorchAvailable()
300
299
  call.resolve([
301
300
  "available": available
302
301
  ])
303
302
  }
304
-
303
+
305
304
  @objc func getTorchMode(_ call: CAPPluginCall) {
306
305
  let torchState = implementation.getTorchMode()
307
306
  call.resolve([
@@ -309,20 +308,20 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
309
308
  "level": torchState.level
310
309
  ])
311
310
  }
312
-
311
+
313
312
  @objc func setTorchMode(_ call: CAPPluginCall) {
314
313
  guard let enabled = call.getBool("enabled") else {
315
314
  call.reject("Enabled parameter is required")
316
315
  return
317
316
  }
318
-
317
+
319
318
  let level = call.getFloat("level") ?? 1.0
320
-
319
+
321
320
  guard level >= 0.0 && level <= 1.0 else {
322
321
  call.reject("Level must be between 0.0 and 1.0")
323
322
  return
324
323
  }
325
-
324
+
326
325
  do {
327
326
  try implementation.setTorchMode(enabled: enabled, level: level)
328
327
  call.resolve()
@@ -330,10 +329,10 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
330
329
  call.reject("Failed to set torch mode", nil, error)
331
330
  }
332
331
  }
333
-
332
+
334
333
  @objc override public func checkPermissions(_ call: CAPPluginCall) {
335
334
  let cameraState: String
336
-
335
+
337
336
  switch AVCaptureDevice.authorizationStatus(for: .video) {
338
337
  case .notDetermined:
339
338
  cameraState = "prompt"
@@ -344,18 +343,18 @@ public class CameraViewPlugin: CAPPlugin, CAPBridgedPlugin {
344
343
  @unknown default:
345
344
  cameraState = "prompt"
346
345
  }
347
-
346
+
348
347
  call.resolve([
349
348
  "camera": cameraState
350
349
  ])
351
350
  }
352
-
351
+
353
352
  @objc override public func requestPermissions(_ call: CAPPluginCall) {
354
353
  AVCaptureDevice.requestAccess(for: .video) { [weak self] _ in
355
354
  self?.checkPermissions(call)
356
355
  }
357
356
  }
358
-
357
+
359
358
  private func maybeRequestCameraAccess(completion: @escaping (Bool) -> Void) {
360
359
  let status = AVCaptureDevice.authorizationStatus(for: .video)
361
360
  if status == .authorized {
@@ -0,0 +1,181 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// Manages temporary files created during camera capture operations.
5
+ /// Provides automatic cleanup to prevent disk space leaks.
6
+ ///
7
+ /// This singleton tracks all temporary files created by the camera plugin
8
+ /// and provides cleanup at various lifecycle points:
9
+ /// - On app activation (cleans up stale files older than 30 minutes)
10
+ /// - On app termination (cleans up all tracked files)
11
+ public final class TempFileManager: @unchecked Sendable {
12
+ /// Shared singleton instance
13
+ public static let shared = TempFileManager()
14
+
15
+ /// Serial queue for thread-safe file tracking
16
+ private let queue = DispatchQueue(label: "com.michaelwolz.capacitorcameraview.tempFileManager")
17
+
18
+ /// Set of tracked temporary file URLs
19
+ private var trackedFiles = Set<URL>()
20
+
21
+ /// Directory prefix used to identify camera capture temp files
22
+ private let tempFilePrefix = "camera_capture_"
23
+
24
+ /// Stale file threshold in seconds (30 minutes)
25
+ private let staleThresholdSeconds: TimeInterval = 1800
26
+
27
+ private init() {
28
+ setupAppLifecycleObservers()
29
+ }
30
+
31
+ deinit {
32
+ NotificationCenter.default.removeObserver(self)
33
+ }
34
+
35
+ // MARK: - Public API
36
+
37
+ /// Creates a temporary file URL for storing captured images and tracks it for cleanup.
38
+ ///
39
+ /// - Returns: A URL pointing to the temporary file location
40
+ /// - Throws: Never throws, always returns a valid temp directory URL
41
+ public func createTempImageFile() -> URL {
42
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
43
+ let fileName = "\(tempFilePrefix)\(timestamp).jpg"
44
+ let tempDir = FileManager.default.temporaryDirectory
45
+ let fileURL = tempDir.appendingPathComponent(fileName)
46
+
47
+ queue.sync {
48
+ trackedFiles.insert(fileURL)
49
+ }
50
+
51
+ return fileURL
52
+ }
53
+
54
+ /// Cleans up all tracked temporary files.
55
+ /// Call this when the camera session stops.
56
+ public func cleanupSessionFiles() {
57
+ queue.async { [weak self] in
58
+ guard let self = self else { return }
59
+
60
+ let filesToDelete = self.trackedFiles
61
+ self.trackedFiles.removeAll()
62
+
63
+ for fileURL in filesToDelete {
64
+ self.deleteFile(at: fileURL)
65
+ }
66
+ }
67
+ }
68
+
69
+ /// Cleans up stale temporary files that are older than the threshold.
70
+ /// This helps recover from cases where cleanup was missed.
71
+ public func cleanupStaleFiles() {
72
+ queue.async { [weak self] in
73
+ guard let self = self else { return }
74
+
75
+ let fileManager = FileManager.default
76
+ let tempDir = fileManager.temporaryDirectory
77
+
78
+ guard let contents = try? fileManager.contentsOfDirectory(
79
+ at: tempDir,
80
+ includingPropertiesForKeys: [.creationDateKey],
81
+ options: .skipsHiddenFiles
82
+ ) else { return }
83
+
84
+ let staleDate = Date().addingTimeInterval(-self.staleThresholdSeconds)
85
+
86
+ for fileURL in contents {
87
+ // Only process our camera capture files
88
+ guard fileURL.lastPathComponent.hasPrefix(self.tempFilePrefix) else { continue }
89
+
90
+ // Check file age
91
+ if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
92
+ let creationDate = attributes[.creationDate] as? Date,
93
+ creationDate < staleDate {
94
+ self.deleteFile(at: fileURL)
95
+ self.trackedFiles.remove(fileURL)
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ /// Cleans up all camera capture temporary files in the temp directory.
102
+ /// Use this for aggressive cleanup on app termination.
103
+ public func cleanupAllCaptureFiles() {
104
+ queue.async { [weak self] in
105
+ guard let self = self else { return }
106
+
107
+ let fileManager = FileManager.default
108
+ let tempDir = fileManager.temporaryDirectory
109
+
110
+ guard let contents = try? fileManager.contentsOfDirectory(
111
+ at: tempDir,
112
+ includingPropertiesForKeys: nil,
113
+ options: .skipsHiddenFiles
114
+ ) else { return }
115
+
116
+ for fileURL in contents {
117
+ if fileURL.lastPathComponent.hasPrefix(self.tempFilePrefix) {
118
+ self.deleteFile(at: fileURL)
119
+ }
120
+ }
121
+
122
+ self.trackedFiles.removeAll()
123
+ }
124
+ }
125
+
126
+ // MARK: - Private Methods
127
+
128
+ private func deleteFile(at url: URL) {
129
+ do {
130
+ try FileManager.default.removeItem(at: url)
131
+ } catch {
132
+ // Silently ignore - file may already be deleted or inaccessible
133
+ }
134
+ }
135
+
136
+ private func setupAppLifecycleObservers() {
137
+ NotificationCenter.default.addObserver(
138
+ self,
139
+ selector: #selector(handleAppDidBecomeActive),
140
+ name: UIApplication.didBecomeActiveNotification,
141
+ object: nil
142
+ )
143
+
144
+ NotificationCenter.default.addObserver(
145
+ self,
146
+ selector: #selector(handleAppWillTerminate),
147
+ name: UIApplication.willTerminateNotification,
148
+ object: nil
149
+ )
150
+ }
151
+
152
+ @objc private func handleAppDidBecomeActive() {
153
+ // Clean up any stale files when app becomes active
154
+ cleanupStaleFiles()
155
+ }
156
+
157
+ @objc private func handleAppWillTerminate() {
158
+ // Clean up all capture files on termination
159
+ // Note: This is synchronous to ensure cleanup happens before termination
160
+ queue.sync { [weak self] in
161
+ guard let self = self else { return }
162
+
163
+ let fileManager = FileManager.default
164
+ let tempDir = fileManager.temporaryDirectory
165
+
166
+ if let contents = try? fileManager.contentsOfDirectory(
167
+ at: tempDir,
168
+ includingPropertiesForKeys: nil,
169
+ options: .skipsHiddenFiles
170
+ ) {
171
+ for fileURL in contents {
172
+ if fileURL.lastPathComponent.hasPrefix(self.tempFilePrefix) {
173
+ try? fileManager.removeItem(at: fileURL)
174
+ }
175
+ }
176
+ }
177
+
178
+ self.trackedFiles.removeAll()
179
+ }
180
+ }
181
+ }