catalyst-core 0.1.0-beta.3 → 0.1.0-beta.5
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.
- package/bin/catalyst.js +8 -1
- package/changelog.md +6 -0
- package/dist/native/androidProject/app/build.gradle.kts +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +17 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +31 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +148 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
- package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
- package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
- package/dist/native/bridge/WebBridge.js +5 -5
- package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
- package/dist/native/bridge/hooks/index.js +1 -0
- package/dist/native/bridge/hooks/useCamera.js +9 -0
- package/dist/native/bridge/hooks/useDataProtection.js +2 -0
- package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
- package/dist/native/bridge/hooks/useFilePicker.js +2 -0
- package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
- package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
- package/dist/native/bridge/hooks/useIntent.js +1 -0
- package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
- package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
- package/dist/native/bridge/hooks/useNotification.js +2 -0
- package/dist/native/bridge/hooks/useSafeArea.js +2 -0
- package/dist/native/bridge/hooks/useVideoStream.js +6 -0
- package/dist/native/bridge/hooks/utils.js +1 -0
- package/dist/native/bridge/hooks.js +1 -137
- package/dist/native/bridge/useBaseHook.js +8 -3
- package/dist/native/bridge/utils/NativeBridge.js +36 -1
- package/dist/native/buildAppAndroid.js +2 -2
- package/dist/native/buildAppIos.js +12 -17
- package/dist/native/internalPluginUtils.js +7 -0
- package/dist/native/iosnativeWebView/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
- package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +38 -7
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +6 -2
- package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +15 -4
- package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +28 -0
- package/dist/native/pluginComposerIos.js +16 -0
- package/dist/otel.js +70 -2
- package/dist/scripts/plugins.js +1 -0
- package/dist/server/renderer/extract.js +3 -3
- package/dist/server/renderer/handler.js +15 -14
- package/dist/webpack/development.client.babel.js +5 -1
- package/dist/webpack/production.ssr.babel.js +5 -1
- package/mcp_v2/knowledge-base.json +1564 -1067
- package/package.json +1 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import UIKit
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "CameraSessionManager")
|
|
7
|
+
|
|
8
|
+
/// Owns the AVCaptureSession lifecycle: configure, start, stop, flip, setFps.
|
|
9
|
+
/// Mirrors Android's CameraSessionManager.kt — same public API shape.
|
|
10
|
+
class CameraSessionManager: NSObject {
|
|
11
|
+
|
|
12
|
+
private let stateMachine: VideoStreamStateMachine
|
|
13
|
+
private let zoomController: ZoomController
|
|
14
|
+
private let torchController: TorchController
|
|
15
|
+
private let barcodeDetector: BarcodeDetector
|
|
16
|
+
private let onReady: () -> Void
|
|
17
|
+
private let onStopped: () -> Void
|
|
18
|
+
private let onError: (String) -> Void
|
|
19
|
+
|
|
20
|
+
private var session: AVCaptureSession?
|
|
21
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
22
|
+
private weak var previewView: UIView?
|
|
23
|
+
private let sessionQueue = DispatchQueue(label: "com.catalyst.camera.session")
|
|
24
|
+
|
|
25
|
+
// Session params — stored so flip/setFps can rebind with same settings.
|
|
26
|
+
// paramsLock guards reads/writes since callers may be on any thread while
|
|
27
|
+
// sessionQueue reads them concurrently.
|
|
28
|
+
private let paramsLock = NSLock()
|
|
29
|
+
private var _currentFacing: String = "back"
|
|
30
|
+
private var _currentFpsMin: Int?
|
|
31
|
+
private var _currentFpsMax: Int?
|
|
32
|
+
private var _currentScanFormat: String = "all"
|
|
33
|
+
private var _currentInitialZoom: Float = 1.0
|
|
34
|
+
|
|
35
|
+
private var currentFacing: String {
|
|
36
|
+
get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFacing }
|
|
37
|
+
set { paramsLock.lock(); _currentFacing = newValue; paramsLock.unlock() }
|
|
38
|
+
}
|
|
39
|
+
private var currentFpsMin: Int? {
|
|
40
|
+
get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFpsMin }
|
|
41
|
+
set { paramsLock.lock(); _currentFpsMin = newValue; paramsLock.unlock() }
|
|
42
|
+
}
|
|
43
|
+
private var currentFpsMax: Int? {
|
|
44
|
+
get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentFpsMax }
|
|
45
|
+
set { paramsLock.lock(); _currentFpsMax = newValue; paramsLock.unlock() }
|
|
46
|
+
}
|
|
47
|
+
private var currentScanFormat: String {
|
|
48
|
+
get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentScanFormat }
|
|
49
|
+
set { paramsLock.lock(); _currentScanFormat = newValue; paramsLock.unlock() }
|
|
50
|
+
}
|
|
51
|
+
private var currentInitialZoom: Float {
|
|
52
|
+
get { paramsLock.lock(); defer { paramsLock.unlock() }; return _currentInitialZoom }
|
|
53
|
+
set { paramsLock.lock(); _currentInitialZoom = newValue; paramsLock.unlock() }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
init(stateMachine: VideoStreamStateMachine,
|
|
57
|
+
zoomController: ZoomController,
|
|
58
|
+
torchController: TorchController,
|
|
59
|
+
barcodeDetector: BarcodeDetector,
|
|
60
|
+
onReady: @escaping () -> Void,
|
|
61
|
+
onStopped: @escaping () -> Void,
|
|
62
|
+
onError: @escaping (String) -> Void) {
|
|
63
|
+
self.stateMachine = stateMachine
|
|
64
|
+
self.zoomController = zoomController
|
|
65
|
+
self.torchController = torchController
|
|
66
|
+
self.barcodeDetector = barcodeDetector
|
|
67
|
+
self.onReady = onReady
|
|
68
|
+
self.onStopped = onStopped
|
|
69
|
+
self.onError = onError
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - Public API
|
|
73
|
+
|
|
74
|
+
func setPreviewView(_ view: UIView) {
|
|
75
|
+
previewView = view
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func start(facing: String,
|
|
79
|
+
initialZoom: Float,
|
|
80
|
+
scanFormat: String,
|
|
81
|
+
fpsMin: Int?,
|
|
82
|
+
fpsMax: Int?) {
|
|
83
|
+
currentFacing = facing
|
|
84
|
+
currentInitialZoom = initialZoom
|
|
85
|
+
currentScanFormat = scanFormat
|
|
86
|
+
currentFpsMin = fpsMin
|
|
87
|
+
currentFpsMax = fpsMax
|
|
88
|
+
|
|
89
|
+
checkPermissionAndBind()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func stop() {
|
|
93
|
+
sessionQueue.async { [weak self] in
|
|
94
|
+
self?.tearDownSession()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func flip() {
|
|
99
|
+
currentFacing = (currentFacing == "back") ? "front" : "back"
|
|
100
|
+
logger.debug("flip() → \(self.currentFacing)")
|
|
101
|
+
sessionQueue.async { [weak self] in
|
|
102
|
+
self?.rebindSession()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func setFps(min: Int?, max: Int?) {
|
|
107
|
+
logger.debug("setFps(\(String(describing: min)), \(String(describing: max))) — applying live or rebind")
|
|
108
|
+
currentFpsMin = min
|
|
109
|
+
currentFpsMax = max
|
|
110
|
+
sessionQueue.async { [weak self] in
|
|
111
|
+
self?.applyFpsToLiveDevice()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func cleanup() {
|
|
116
|
+
sessionQueue.async { [weak self] in
|
|
117
|
+
self?.tearDownSession()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MARK: - Permission
|
|
122
|
+
|
|
123
|
+
private func checkPermissionAndBind() {
|
|
124
|
+
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
125
|
+
case .authorized:
|
|
126
|
+
sessionQueue.async { [weak self] in self?.bindSession() }
|
|
127
|
+
case .notDetermined:
|
|
128
|
+
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
129
|
+
if granted {
|
|
130
|
+
self?.sessionQueue.async { self?.bindSession() }
|
|
131
|
+
} else {
|
|
132
|
+
self?.stateMachine.transition(to: .idle)
|
|
133
|
+
self?.onError("Camera permission denied")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
default:
|
|
137
|
+
stateMachine.transition(to: .idle)
|
|
138
|
+
onError("Camera permission denied")
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Session lifecycle
|
|
143
|
+
|
|
144
|
+
private func bindSession() {
|
|
145
|
+
let newSession = AVCaptureSession()
|
|
146
|
+
newSession.beginConfiguration()
|
|
147
|
+
newSession.sessionPreset = .hd1920x1080
|
|
148
|
+
|
|
149
|
+
// Input
|
|
150
|
+
let position: AVCaptureDevice.Position = (currentFacing == "front") ? .front : .back
|
|
151
|
+
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position),
|
|
152
|
+
let input = try? AVCaptureDeviceInput(device: device) else {
|
|
153
|
+
stateMachine.transition(to: .idle)
|
|
154
|
+
onError("Failed to open camera device")
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
guard newSession.canAddInput(input) else {
|
|
158
|
+
stateMachine.transition(to: .idle)
|
|
159
|
+
onError("Cannot add camera input")
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
newSession.addInput(input)
|
|
163
|
+
|
|
164
|
+
// FPS
|
|
165
|
+
configureFPS(device: device, fpsMin: currentFpsMin, fpsMax: currentFpsMax)
|
|
166
|
+
|
|
167
|
+
// Preview layer — must be created before addOutput so transforms are correct
|
|
168
|
+
let layer = AVCaptureVideoPreviewLayer(session: newSession)
|
|
169
|
+
layer.videoGravity = .resizeAspectFill
|
|
170
|
+
|
|
171
|
+
newSession.commitConfiguration()
|
|
172
|
+
|
|
173
|
+
// Attach barcode detector (adds AVCaptureMetadataOutput to session)
|
|
174
|
+
barcodeDetector.attach(to: newSession, previewLayer: layer, scanFormat: currentScanFormat)
|
|
175
|
+
|
|
176
|
+
// Controllers
|
|
177
|
+
zoomController.attachDevice(device)
|
|
178
|
+
torchController.attachDevice(device, facing: currentFacing)
|
|
179
|
+
|
|
180
|
+
// Apply initial zoom before starting
|
|
181
|
+
zoomController.applyZoomMultiplier(CGFloat(currentInitialZoom))
|
|
182
|
+
torchController.notifyReset()
|
|
183
|
+
|
|
184
|
+
self.session = newSession
|
|
185
|
+
self.previewLayer = layer
|
|
186
|
+
|
|
187
|
+
newSession.startRunning()
|
|
188
|
+
|
|
189
|
+
// Embed preview layer and fire onReady on main thread.
|
|
190
|
+
// Preview layer insertion is best-effort — previewView is a weak ref to a
|
|
191
|
+
// SwiftUI-managed UIView that should always be ready by the time JS calls start(),
|
|
192
|
+
// but we don't gate onReady() on it. Matches Android: onReady fires as soon as
|
|
193
|
+
// the camera is bound, regardless of the preview container state.
|
|
194
|
+
DispatchQueue.main.async { [weak self] in
|
|
195
|
+
guard let self else { return }
|
|
196
|
+
if let view = self.previewView {
|
|
197
|
+
layer.frame = view.bounds
|
|
198
|
+
view.layer.insertSublayer(layer, at: 0)
|
|
199
|
+
}
|
|
200
|
+
self.stateMachine.transition(to: .streaming)
|
|
201
|
+
self.onReady()
|
|
202
|
+
logger.debug("Session started — facing=\(self.currentFacing)")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func tearDownSession() {
|
|
207
|
+
guard let session else { return }
|
|
208
|
+
session.stopRunning()
|
|
209
|
+
barcodeDetector.detach(from: session)
|
|
210
|
+
zoomController.detachDevice()
|
|
211
|
+
torchController.detachDevice()
|
|
212
|
+
self.session = nil
|
|
213
|
+
|
|
214
|
+
DispatchQueue.main.async { [weak self] in
|
|
215
|
+
self?.previewLayer?.removeFromSuperlayer()
|
|
216
|
+
self?.previewLayer = nil
|
|
217
|
+
self?.stateMachine.transition(to: .idle)
|
|
218
|
+
self?.onStopped()
|
|
219
|
+
}
|
|
220
|
+
logger.debug("Session torn down")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func rebindSession() {
|
|
224
|
+
guard let session else { return }
|
|
225
|
+
session.stopRunning()
|
|
226
|
+
barcodeDetector.detach(from: session)
|
|
227
|
+
zoomController.detachDevice()
|
|
228
|
+
torchController.detachDevice()
|
|
229
|
+
self.session = nil
|
|
230
|
+
let oldPreview = self.previewLayer
|
|
231
|
+
self.previewLayer = nil
|
|
232
|
+
DispatchQueue.main.async {
|
|
233
|
+
oldPreview?.removeFromSuperlayer()
|
|
234
|
+
}
|
|
235
|
+
bindSession()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Apply FPS change to the live device without stopping/restarting the session.
|
|
239
|
+
/// Eliminates the screen blank that rebindSession() causes.
|
|
240
|
+
/// Falls back to rebindSession() if no active session exists yet.
|
|
241
|
+
private func applyFpsToLiveDevice() {
|
|
242
|
+
guard let session,
|
|
243
|
+
let input = session.inputs.first as? AVCaptureDeviceInput else {
|
|
244
|
+
logger.warning("applyFpsToLiveDevice — no active session or input, skipping (stream not yet started)")
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
let device = input.device
|
|
248
|
+
guard let fpsMin = currentFpsMin, let fpsMax = currentFpsMax else {
|
|
249
|
+
logger.debug("applyFpsToLiveDevice — no FPS values set, skipping")
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let targetMin = CMTime(value: 1, timescale: CMTimeScale(fpsMin))
|
|
254
|
+
let targetMax = CMTime(value: 1, timescale: CMTimeScale(fpsMax))
|
|
255
|
+
|
|
256
|
+
let supported = device.activeFormat.videoSupportedFrameRateRanges.first { range in
|
|
257
|
+
range.minFrameDuration <= targetMin && range.maxFrameDuration >= targetMax
|
|
258
|
+
}
|
|
259
|
+
guard supported != nil else {
|
|
260
|
+
logger.warning("applyFpsToLiveDevice — FPS \(fpsMin)-\(fpsMax) unsupported by current format, falling back to rebind")
|
|
261
|
+
rebindSession()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Wrap in beginConfiguration/commitConfiguration to prevent AVCaptureSession
|
|
266
|
+
// from triggering an internal _buildAndRunGraph (which causes a screen blank).
|
|
267
|
+
// lockForConfiguration must be called inside the begin/commit pair.
|
|
268
|
+
session.beginConfiguration()
|
|
269
|
+
do {
|
|
270
|
+
try device.lockForConfiguration()
|
|
271
|
+
device.activeVideoMinFrameDuration = targetMax // fastest rate = 1/maxFps
|
|
272
|
+
device.activeVideoMaxFrameDuration = targetMin // slowest rate = 1/minFps
|
|
273
|
+
device.unlockForConfiguration()
|
|
274
|
+
} catch {
|
|
275
|
+
session.commitConfiguration()
|
|
276
|
+
logger.error("applyFpsToLiveDevice — lockForConfiguration failed: \(error.localizedDescription), falling back to rebind")
|
|
277
|
+
rebindSession()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
session.commitConfiguration()
|
|
281
|
+
logger.debug("applyFpsToLiveDevice — FPS set to \(fpsMin)-\(fpsMax) without session rebind")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// MARK: - FPS configuration
|
|
285
|
+
|
|
286
|
+
private func configureFPS(device: AVCaptureDevice, fpsMin: Int?, fpsMax: Int?) {
|
|
287
|
+
guard let fpsMin, let fpsMax else { return }
|
|
288
|
+
let targetMin = CMTime(value: 1, timescale: CMTimeScale(fpsMin))
|
|
289
|
+
let targetMax = CMTime(value: 1, timescale: CMTimeScale(fpsMax))
|
|
290
|
+
|
|
291
|
+
let supported = device.activeFormat.videoSupportedFrameRateRanges.first { range in
|
|
292
|
+
range.minFrameDuration <= targetMin && range.maxFrameDuration >= targetMax
|
|
293
|
+
}
|
|
294
|
+
guard supported != nil else {
|
|
295
|
+
logger.warning("FPS range \(fpsMin)-\(fpsMax) not supported by device format, ignoring")
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
do {
|
|
299
|
+
try device.lockForConfiguration()
|
|
300
|
+
device.activeVideoMinFrameDuration = targetMax // fastest rate = 1/maxFps
|
|
301
|
+
device.activeVideoMaxFrameDuration = targetMin // slowest rate = 1/minFps
|
|
302
|
+
device.unlockForConfiguration()
|
|
303
|
+
logger.debug("FPS set to \(fpsMin)-\(fpsMax)")
|
|
304
|
+
} catch {
|
|
305
|
+
logger.error("Failed to set FPS: \(error.localizedDescription)")
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "HoldController")
|
|
5
|
+
|
|
6
|
+
/// Manages QR hold state — after each detection, results are suppressed for HOLD_DURATION
|
|
7
|
+
/// to prevent the same QR from firing repeatedly.
|
|
8
|
+
/// Uses a suppressResults flag on BarcodeDetector instead of stopping the session,
|
|
9
|
+
/// so the camera pipeline stays live and there is no flicker.
|
|
10
|
+
class HoldController {
|
|
11
|
+
|
|
12
|
+
private let holdDuration: TimeInterval = 0.2 // 200ms, matches Android
|
|
13
|
+
|
|
14
|
+
private let stateMachine: VideoStreamStateMachine
|
|
15
|
+
private let barcodeDetector: BarcodeDetector
|
|
16
|
+
private var holdWorkItem: DispatchWorkItem?
|
|
17
|
+
private let serialQueue = DispatchQueue(label: "com.catalyst.camera.hold")
|
|
18
|
+
|
|
19
|
+
private(set) var lastDetectedValue: String?
|
|
20
|
+
|
|
21
|
+
init(stateMachine: VideoStreamStateMachine, barcodeDetector: BarcodeDetector) {
|
|
22
|
+
self.stateMachine = stateMachine
|
|
23
|
+
self.barcodeDetector = barcodeDetector
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Call after a new QR value is confirmed.
|
|
27
|
+
func startHold() {
|
|
28
|
+
guard stateMachine.transition(to: .hold) else { return }
|
|
29
|
+
|
|
30
|
+
barcodeDetector.suppressResults = true
|
|
31
|
+
logger.debug("Hold started — results suppressed for \(self.holdDuration * 1000, format: .fixed(precision: 0))ms")
|
|
32
|
+
|
|
33
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
34
|
+
guard let self, self.stateMachine.isActive else { return }
|
|
35
|
+
self.barcodeDetector.suppressResults = false
|
|
36
|
+
self.stateMachine.transition(to: .streaming)
|
|
37
|
+
logger.debug("Hold ended — results resumed")
|
|
38
|
+
}
|
|
39
|
+
holdWorkItem = workItem
|
|
40
|
+
serialQueue.asyncAfter(deadline: .now() + holdDuration, execute: workItem)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Call on stop() or flip() to cancel any pending hold and clear detection memory.
|
|
44
|
+
func reset() {
|
|
45
|
+
serialQueue.async { [weak self] in
|
|
46
|
+
guard let self else { return }
|
|
47
|
+
self.holdWorkItem?.cancel()
|
|
48
|
+
self.holdWorkItem = nil
|
|
49
|
+
self.barcodeDetector.suppressResults = false
|
|
50
|
+
self.lastDetectedValue = nil
|
|
51
|
+
logger.debug("Hold state reset")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Returns true if this value is new (not a repeat of the last detected value).
|
|
56
|
+
func isNewValue(_ value: String) -> Bool {
|
|
57
|
+
serialQueue.sync {
|
|
58
|
+
if value == lastDetectedValue { return false }
|
|
59
|
+
lastDetectedValue = value
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import UIKit
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "NativeCameraManager")
|
|
7
|
+
|
|
8
|
+
/// Mutable reference wrapper so all sub-controller closures share the same callback
|
|
9
|
+
/// even after rewireEvents()/rewireError() replace it post-construction.
|
|
10
|
+
/// NSLock guards concurrent handler replacement vs. invocation.
|
|
11
|
+
private final class EventBox {
|
|
12
|
+
private let lock = NSLock()
|
|
13
|
+
private var _handler: (String, [String: Any]?) -> Void
|
|
14
|
+
init(_ h: @escaping (String, [String: Any]?) -> Void) { _handler = h }
|
|
15
|
+
|
|
16
|
+
func call(_ event: String, _ payload: [String: Any]?) {
|
|
17
|
+
lock.lock()
|
|
18
|
+
let h = _handler
|
|
19
|
+
lock.unlock()
|
|
20
|
+
h(event, payload)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func replace(_ h: @escaping (String, [String: Any]?) -> Void) {
|
|
24
|
+
lock.lock()
|
|
25
|
+
_handler = h
|
|
26
|
+
lock.unlock()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
private final class ErrorBox {
|
|
30
|
+
private let lock = NSLock()
|
|
31
|
+
private var _handler: (String) -> Void
|
|
32
|
+
init(_ h: @escaping (String) -> Void) { _handler = h }
|
|
33
|
+
|
|
34
|
+
func call(_ msg: String) {
|
|
35
|
+
lock.lock()
|
|
36
|
+
let h = _handler
|
|
37
|
+
lock.unlock()
|
|
38
|
+
h(msg)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func replace(_ h: @escaping (String) -> Void) {
|
|
42
|
+
lock.lock()
|
|
43
|
+
_handler = h
|
|
44
|
+
lock.unlock()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Thin facade — wires all camera sub-components and exposes the public API
|
|
49
|
+
/// that NativeBridge.swift calls. Mirrors Android's NativeCameraManager.kt.
|
|
50
|
+
public class NativeCameraManager: ObservableObject {
|
|
51
|
+
|
|
52
|
+
private let stateMachine: VideoStreamStateMachine
|
|
53
|
+
private let zoomController: ZoomController
|
|
54
|
+
private let torchController: TorchController
|
|
55
|
+
private let barcodeDetector: BarcodeDetector
|
|
56
|
+
private let sessionManager: CameraSessionManager
|
|
57
|
+
private let holdController: HoldController
|
|
58
|
+
|
|
59
|
+
// Boxes — all sub-controller closures close over these references,
|
|
60
|
+
// so updating .handler here propagates to all of them immediately.
|
|
61
|
+
private let eventBox: EventBox
|
|
62
|
+
private let errorBox: ErrorBox
|
|
63
|
+
|
|
64
|
+
public init(onEvent: @escaping (String, [String: Any]?) -> Void,
|
|
65
|
+
onError: @escaping (String) -> Void) {
|
|
66
|
+
let eBox = EventBox(onEvent)
|
|
67
|
+
let errBox = ErrorBox(onError)
|
|
68
|
+
self.eventBox = eBox
|
|
69
|
+
self.errorBox = errBox
|
|
70
|
+
|
|
71
|
+
let sm = VideoStreamStateMachine()
|
|
72
|
+
self.stateMachine = sm
|
|
73
|
+
|
|
74
|
+
zoomController = ZoomController(
|
|
75
|
+
stateMachine: sm,
|
|
76
|
+
onZoomChanged: { zoomLevel, minZoom, maxZoom in
|
|
77
|
+
let payload: [String: Any] = [
|
|
78
|
+
"zoomLevel": round(Double(zoomLevel) * 10) / 10,
|
|
79
|
+
"minZoom": round(Double(minZoom) * 10) / 10,
|
|
80
|
+
"maxZoom": round(Double(maxZoom) * 10) / 10
|
|
81
|
+
]
|
|
82
|
+
eBox.call("ON_ZOOM_CHANGED", payload)
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
torchController = TorchController(
|
|
87
|
+
onTorchChanged: { enabled in
|
|
88
|
+
eBox.call("ON_TORCH_CHANGED", ["enabled": enabled])
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
barcodeDetector = BarcodeDetector()
|
|
93
|
+
|
|
94
|
+
sessionManager = CameraSessionManager(
|
|
95
|
+
stateMachine: sm,
|
|
96
|
+
zoomController: zoomController,
|
|
97
|
+
torchController: torchController,
|
|
98
|
+
barcodeDetector: barcodeDetector,
|
|
99
|
+
onReady: { eBox.call("ON_VIDEO_STREAM_READY", nil) },
|
|
100
|
+
onStopped: { eBox.call("ON_VIDEO_STREAM_STOPPED", nil) },
|
|
101
|
+
onError: { msg in errBox.call(msg) }
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
holdController = HoldController(stateMachine: sm, barcodeDetector: barcodeDetector)
|
|
105
|
+
|
|
106
|
+
// Wire detection handler now that holdController exists
|
|
107
|
+
barcodeDetector.detectionHandler = { [weak holdController, weak sm] value, type, bounds in
|
|
108
|
+
guard sm?.state != .hold else { return }
|
|
109
|
+
guard let hc = holdController else { return }
|
|
110
|
+
if hc.isNewValue(value) {
|
|
111
|
+
let payload: [String: Any] = ["value": value, "format": BarcodeDetector.formatName(type)]
|
|
112
|
+
eBox.call("ON_QR_DETECTED", payload)
|
|
113
|
+
logger.debug("QR detected (new): \(value)")
|
|
114
|
+
}
|
|
115
|
+
hc.startHold()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - Public API
|
|
120
|
+
|
|
121
|
+
public func setPreviewView(_ view: UIView) {
|
|
122
|
+
sessionManager.setPreviewView(view)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public func start(facing: String = "back",
|
|
126
|
+
autoZoom: Bool = false, // accepted, silently ignored on iOS
|
|
127
|
+
initialZoom: Float = 1.0,
|
|
128
|
+
scanFormat: String = "all",
|
|
129
|
+
fpsMin: Int? = nil,
|
|
130
|
+
fpsMax: Int? = nil) {
|
|
131
|
+
guard !stateMachine.isActive else { return }
|
|
132
|
+
stateMachine.transition(to: .starting)
|
|
133
|
+
sessionManager.start(
|
|
134
|
+
facing: facing,
|
|
135
|
+
initialZoom: initialZoom,
|
|
136
|
+
scanFormat: scanFormat,
|
|
137
|
+
fpsMin: fpsMin,
|
|
138
|
+
fpsMax: fpsMax
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public func stop() {
|
|
143
|
+
guard stateMachine.isActive else { return }
|
|
144
|
+
holdController.reset()
|
|
145
|
+
stateMachine.transition(to: .stopping)
|
|
146
|
+
sessionManager.stop()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public func flip() {
|
|
150
|
+
guard stateMachine.isActive else { return }
|
|
151
|
+
holdController.reset()
|
|
152
|
+
stateMachine.transition(to: .flipping)
|
|
153
|
+
sessionManager.flip()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public func setZoom(multiplier: Float) {
|
|
157
|
+
zoomController.setZoom(multiplier: multiplier)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public func setTorch(_ on: Bool) {
|
|
161
|
+
torchController.setTorch(on)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public func setFps(min: Int?, max: Int?) {
|
|
165
|
+
guard stateMachine.isActive else { return }
|
|
166
|
+
sessionManager.setFps(min: min, max: max)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public func handlePinchBegan() {
|
|
170
|
+
zoomController.handlePinchBegan()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public func handlePinchChanged(scale: CGFloat) {
|
|
174
|
+
zoomController.handlePinchChanged(scale: scale)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public func cleanup() {
|
|
178
|
+
sessionManager.cleanup()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Replace the event callback post-construction (called by NativeBridge after init).
|
|
182
|
+
/// Updates the shared EventBox so all sub-controller closures see the new handler.
|
|
183
|
+
public func rewireEvents(_ handler: @escaping (String, [String: Any]?) -> Void) {
|
|
184
|
+
eventBox.replace(handler)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Replace the error callback post-construction (called by NativeBridge after init).
|
|
188
|
+
/// Updates the shared ErrorBox so all sub-controller closures see the new handler.
|
|
189
|
+
public func rewireError(_ handler: @escaping (String) -> Void) {
|
|
190
|
+
errorBox.replace(handler)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public var isStreaming: Bool { stateMachine.isActive }
|
|
194
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "TorchController")
|
|
6
|
+
|
|
7
|
+
/// Manages torch (flashlight) state.
|
|
8
|
+
/// Guards against front-camera torch calls.
|
|
9
|
+
/// Fires ON_TORCH_CHANGED via onTorchChanged callback.
|
|
10
|
+
class TorchController {
|
|
11
|
+
|
|
12
|
+
private var device: AVCaptureDevice?
|
|
13
|
+
private var currentFacing: String = "back"
|
|
14
|
+
private let onTorchChanged: (Bool) -> Void
|
|
15
|
+
|
|
16
|
+
init(onTorchChanged: @escaping (Bool) -> Void) {
|
|
17
|
+
self.onTorchChanged = onTorchChanged
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func attachDevice(_ device: AVCaptureDevice, facing: String) {
|
|
21
|
+
self.device = device
|
|
22
|
+
self.currentFacing = facing
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func detachDevice() {
|
|
26
|
+
device = nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func setTorch(_ on: Bool) {
|
|
30
|
+
guard let device else {
|
|
31
|
+
logger.warning("setTorch(\(on)) — device is nil, skipping")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
guard currentFacing == "back" else {
|
|
35
|
+
logger.warning("setTorch(\(on)) — front camera has no torch, ignoring")
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
guard device.hasTorch else {
|
|
39
|
+
logger.warning("setTorch(\(on)) — device has no torch")
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
do {
|
|
44
|
+
try device.lockForConfiguration()
|
|
45
|
+
device.torchMode = on ? .on : .off
|
|
46
|
+
device.unlockForConfiguration()
|
|
47
|
+
onTorchChanged(on)
|
|
48
|
+
logger.debug("setTorch(\(on))")
|
|
49
|
+
} catch {
|
|
50
|
+
logger.error("setTorch(\(on)) failed: \(error.localizedDescription)")
|
|
51
|
+
onTorchChanged(false)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Called after every session bind — torch always resets to off on session start.
|
|
56
|
+
func notifyReset() {
|
|
57
|
+
onTorchChanged(false)
|
|
58
|
+
logger.debug("Torch reset to off (new session)")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - VideoStreamState
|
|
4
|
+
//
|
|
5
|
+
// Valid transitions:
|
|
6
|
+
// IDLE → STARTING (start() called)
|
|
7
|
+
// STARTING → STREAMING (session running)
|
|
8
|
+
// STARTING → IDLE (permission denied or bind failure)
|
|
9
|
+
// STREAMING → HOLD (QR detected)
|
|
10
|
+
// HOLD → STREAMING (hold expired)
|
|
11
|
+
// STREAMING → FLIPPING (flip() called)
|
|
12
|
+
// FLIPPING → STREAMING (rebind complete)
|
|
13
|
+
// STREAMING → STOPPING (stop() called)
|
|
14
|
+
// FLIPPING → STOPPING (stop() called mid-flip)
|
|
15
|
+
// HOLD → STOPPING (stop() called during hold)
|
|
16
|
+
// STOPPING → IDLE (session torn down)
|
|
17
|
+
|
|
18
|
+
enum VideoStreamState {
|
|
19
|
+
case idle
|
|
20
|
+
case starting
|
|
21
|
+
case streaming
|
|
22
|
+
case hold
|
|
23
|
+
case flipping
|
|
24
|
+
case stopping
|
|
25
|
+
|
|
26
|
+
func canTransitionTo(_ next: VideoStreamState) -> Bool {
|
|
27
|
+
switch self {
|
|
28
|
+
case .idle: return next == .starting
|
|
29
|
+
case .starting: return next == .streaming || next == .idle
|
|
30
|
+
case .streaming: return next == .hold || next == .flipping || next == .stopping
|
|
31
|
+
case .hold: return next == .streaming || next == .stopping
|
|
32
|
+
case .flipping: return next == .streaming || next == .stopping
|
|
33
|
+
case .stopping: return next == .idle
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var isActive: Bool {
|
|
38
|
+
return self == .streaming || self == .hold || self == .flipping
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protocol VideoStreamStateListener: AnyObject {
|
|
43
|
+
func onStateChanged(prev: VideoStreamState, next: VideoStreamState)
|
|
44
|
+
}
|