@trustchex/react-native-sdk 1.464.0 → 1.475.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.
- package/android/build.gradle +3 -3
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +190 -12
- package/ios/Camera/TrustchexCameraView.swift +244 -46
- package/ios/Permission/PermissionModule.m +22 -0
- package/ios/Permission/PermissionModule.swift +67 -0
- package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
- package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +24 -3
- package/lib/module/Screens/Dynamic/VerbalConsentScreen.js +1 -1
- package/lib/module/Screens/Dynamic/VideoCallScreen.js +7 -12
- package/lib/module/Screens/Static/ResultScreen.js +9 -1
- package/lib/module/Shared/Components/EIDScanner.js +166 -163
- package/lib/module/Shared/Components/FaceCamera.js +14 -8
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +45 -10
- package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/lib/module/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
- package/lib/module/Shared/Libs/index.js +20 -0
- package/lib/module/Shared/Libs/mrz.utils.js +570 -16
- package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
- package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
- package/lib/module/Shared/Libs/permissions.utils.js +199 -0
- package/lib/module/Translation/Resources/en.js +1 -0
- package/lib/module/Translation/Resources/tr.js +1 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
- package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/index.d.ts +20 -0
- package/lib/typescript/src/Shared/Libs/index.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
- package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
- package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
- package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts +58 -0
- package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts.map +1 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
- package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
- package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
- package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +14 -10
- package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
- package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +45 -5
- package/src/Screens/Dynamic/VerbalConsentScreen.tsx +1 -1
- package/src/Screens/Dynamic/VideoCallScreen.tsx +8 -19
- package/src/Screens/Static/ResultScreen.tsx +18 -9
- package/src/Shared/Components/EIDScanner.tsx +210 -160
- package/src/Shared/Components/FaceCamera.tsx +19 -16
- package/src/Shared/Components/IdentityDocumentCamera.tsx +53 -13
- package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
- package/src/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
- package/src/Shared/Libs/index.ts +63 -0
- package/src/Shared/Libs/mrz.utils.ts +639 -11
- package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
- package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
- package/src/Shared/Libs/permissions.utils.ts +251 -0
- package/src/Translation/Resources/en.ts +1 -0
- package/src/Translation/Resources/tr.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -115,7 +115,24 @@ class TrustchexCameraView: UIView {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
private func configureAudioSession() {
|
|
119
|
+
do {
|
|
120
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
121
|
+
// Set category to playAndRecord so we can record audio during video
|
|
122
|
+
try audioSession.setCategory(
|
|
123
|
+
.playAndRecord,
|
|
124
|
+
options: [.defaultToSpeaker, .duckOthers]
|
|
125
|
+
)
|
|
126
|
+
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
|
127
|
+
} catch {
|
|
128
|
+
// Audio session configuration failed - recording may not have audio
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
private func configureCaptureSession() {
|
|
133
|
+
// Configure audio session for recording with microphone
|
|
134
|
+
configureAudioSession()
|
|
135
|
+
|
|
119
136
|
let session = AVCaptureSession()
|
|
120
137
|
session.beginConfiguration()
|
|
121
138
|
|
|
@@ -178,11 +195,16 @@ class TrustchexCameraView: UIView {
|
|
|
178
195
|
let movieOutput = AVCaptureMovieFileOutput()
|
|
179
196
|
if session.canAddOutput(movieOutput) {
|
|
180
197
|
session.addOutput(movieOutput)
|
|
198
|
+
// Configure video connection
|
|
181
199
|
if let connection = movieOutput.connection(with: .video) {
|
|
182
200
|
if connection.isVideoOrientationSupported {
|
|
183
201
|
connection.videoOrientation = .portrait
|
|
184
202
|
}
|
|
185
203
|
}
|
|
204
|
+
// Configure audio connection for recording
|
|
205
|
+
if let audioConnection = movieOutput.connection(with: .audio) {
|
|
206
|
+
audioConnection.isEnabled = true
|
|
207
|
+
}
|
|
186
208
|
}
|
|
187
209
|
self.movieFileOutput = movieOutput
|
|
188
210
|
|
|
@@ -235,36 +257,85 @@ class TrustchexCameraView: UIView {
|
|
|
235
257
|
}
|
|
236
258
|
|
|
237
259
|
// MARK: - Camera Selection
|
|
260
|
+
|
|
261
|
+
/// Selects the best camera for the requested position using the same strategy
|
|
262
|
+
/// as the Android side:
|
|
263
|
+
/// • Front (face detection / verbal consent): pick the device with the widest
|
|
264
|
+
/// field of view by measuring `videoFieldOfView` across all available front
|
|
265
|
+
/// cameras — mirrors Android's focal-length comparison via Camera2.
|
|
266
|
+
/// • Back (document / EID / barcode scanning): wide-angle camera gives the
|
|
267
|
+
/// least barrel distortion and best optical sharpness; ultra-wide is avoided
|
|
268
|
+
/// intentionally because its distortion degrades OCR/MRZ accuracy.
|
|
238
269
|
private func selectBestCamera(for position: AVCaptureDevice.Position) -> AVCaptureDevice? {
|
|
239
270
|
if position == .front {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
271
|
+
return selectWidestFrontCamera()
|
|
272
|
+
}
|
|
273
|
+
return selectBestBackCamera()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Enumerates every front-facing camera the device exposes and returns the one
|
|
277
|
+
/// with the highest reported field-of-view, matching Android's
|
|
278
|
+
/// `selectWidestFrontCamera()` which picks the shortest focal length.
|
|
279
|
+
private func selectWidestFrontCamera() -> AVCaptureDevice? {
|
|
280
|
+
let discovery = AVCaptureDevice.DiscoverySession(
|
|
281
|
+
deviceTypes: [
|
|
282
|
+
.builtInUltraWideCamera,
|
|
283
|
+
.builtInTrueDepthCamera,
|
|
284
|
+
.builtInWideAngleCamera,
|
|
285
|
+
],
|
|
286
|
+
mediaType: .video,
|
|
287
|
+
position: .front
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
let devices = discovery.devices
|
|
291
|
+
guard !devices.isEmpty else {
|
|
292
|
+
return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
|
|
249
293
|
}
|
|
250
294
|
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
295
|
+
// Single device — no comparison needed.
|
|
296
|
+
if devices.count == 1 { return devices.first }
|
|
297
|
+
|
|
298
|
+
// Multiple front cameras: measure the maximum videoFieldOfView each device
|
|
299
|
+
// supports across all its formats and return the highest (widest) one.
|
|
300
|
+
var bestDevice: AVCaptureDevice? = nil
|
|
301
|
+
var widestFoV: Float = 0
|
|
302
|
+
|
|
303
|
+
for device in devices {
|
|
304
|
+
let maxFoV = device.formats
|
|
305
|
+
.map { $0.videoFieldOfView }
|
|
306
|
+
.max() ?? 0
|
|
307
|
+
if maxFoV > widestFoV {
|
|
308
|
+
widestFoV = maxFoV
|
|
309
|
+
bestDevice = device
|
|
310
|
+
}
|
|
255
311
|
}
|
|
256
312
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
313
|
+
return bestDevice
|
|
314
|
+
?? AVCaptureDevice.default(.builtInTrueDepthCamera, for: .video, position: .front)
|
|
315
|
+
?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Returns the best back-facing camera for document scanning scenarios.
|
|
319
|
+
/// Wide-angle is preferred over ultra-wide because it has negligible barrel
|
|
320
|
+
/// distortion — critical for OCR/MRZ accuracy. This matches Android which
|
|
321
|
+
/// resolves to DEFAULT_BACK_CAMERA (the main wide-angle lens).
|
|
322
|
+
private func selectBestBackCamera() -> AVCaptureDevice? {
|
|
323
|
+
// Primary: main wide-angle lens — best for document/EID/barcode scanning.
|
|
324
|
+
if let cam = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
|
325
|
+
return cam
|
|
260
326
|
}
|
|
261
327
|
|
|
262
|
-
// Fallback:
|
|
263
|
-
if let
|
|
264
|
-
return
|
|
328
|
+
// Fallback: dual-camera system (manages wide + telephoto; exposes wide by default).
|
|
329
|
+
if let cam = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back) {
|
|
330
|
+
return cam
|
|
265
331
|
}
|
|
266
332
|
|
|
267
|
-
//
|
|
333
|
+
// Fallback: triple-camera system.
|
|
334
|
+
if let cam = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) {
|
|
335
|
+
return cam
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Final fallback: any available camera.
|
|
268
339
|
return AVCaptureDevice.default(for: .video)
|
|
269
340
|
}
|
|
270
341
|
|
|
@@ -304,6 +375,12 @@ class TrustchexCameraView: UIView {
|
|
|
304
375
|
} else if camera.isFocusModeSupported(.autoFocus) {
|
|
305
376
|
camera.focusMode = .autoFocus
|
|
306
377
|
}
|
|
378
|
+
|
|
379
|
+
// Use the widest front-camera framing available on the device.
|
|
380
|
+
let widestZoom = camera.minAvailableVideoZoomFactor
|
|
381
|
+
if camera.videoZoomFactor != widestZoom {
|
|
382
|
+
camera.videoZoomFactor = widestZoom
|
|
383
|
+
}
|
|
307
384
|
} else {
|
|
308
385
|
// Back camera: Optimized focus for document scanning across all models
|
|
309
386
|
if camera.isFocusModeSupported(.continuousAutoFocus) {
|
|
@@ -476,6 +553,22 @@ class TrustchexCameraView: UIView {
|
|
|
476
553
|
guard let session = self.captureSession, session.isRunning else { return }
|
|
477
554
|
guard let movieOutput = self.movieFileOutput else { return }
|
|
478
555
|
|
|
556
|
+
// Ensure recording session is configured for video + microphone capture.
|
|
557
|
+
do {
|
|
558
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
559
|
+
try audioSession.setCategory(
|
|
560
|
+
.playAndRecord,
|
|
561
|
+
mode: .videoRecording,
|
|
562
|
+
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]
|
|
563
|
+
)
|
|
564
|
+
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
|
565
|
+
} catch {
|
|
566
|
+
DispatchQueue.main.async { [weak self] in
|
|
567
|
+
self?.onRecordingError?(["error": "Failed to activate audio session for recording"])
|
|
568
|
+
}
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
479
572
|
// Check actual AVFoundation recording state
|
|
480
573
|
if movieOutput.isRecording {
|
|
481
574
|
movieOutput.stopRecording()
|
|
@@ -495,12 +588,26 @@ class TrustchexCameraView: UIView {
|
|
|
495
588
|
return
|
|
496
589
|
}
|
|
497
590
|
|
|
498
|
-
// Verify video
|
|
499
|
-
guard movieOutput.connection(with: .video) != nil else {
|
|
591
|
+
// Verify both video and audio connections exist before recording
|
|
592
|
+
guard movieOutput.connection(with: .video) != nil else {
|
|
593
|
+
DispatchQueue.main.async { [weak self] in
|
|
594
|
+
self?.onRecordingError?(["error": "Video connection not available"])
|
|
595
|
+
}
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Audio connection is required for verbal consent recordings.
|
|
600
|
+
guard let audioConnection = movieOutput.connection(with: .audio) else {
|
|
601
|
+
DispatchQueue.main.async { [weak self] in
|
|
602
|
+
self?.onRecordingError?(["error": "Microphone audio connection not available"])
|
|
603
|
+
}
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
audioConnection.isEnabled = true
|
|
500
607
|
|
|
501
608
|
// Create temporary file URL
|
|
502
609
|
let tempDir = NSTemporaryDirectory()
|
|
503
|
-
let fileName = "recording_\(Date().timeIntervalSince1970).
|
|
610
|
+
let fileName = "recording_\(Date().timeIntervalSince1970).mov"
|
|
504
611
|
let fileURL = URL(fileURLWithPath: tempDir).appendingPathComponent(fileName)
|
|
505
612
|
|
|
506
613
|
// Remove existing file if any
|
|
@@ -655,10 +762,18 @@ class TrustchexCameraView: UIView {
|
|
|
655
762
|
|
|
656
763
|
// Update mirroring on preview layer
|
|
657
764
|
DispatchQueue.main.async { [weak self] in
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
765
|
+
guard let connection = self?.previewLayer?.connection else { return }
|
|
766
|
+
guard connection.isVideoMirroringSupported else { return }
|
|
767
|
+
|
|
768
|
+
// Avoid AVFoundation exception when setting explicit mirroring while
|
|
769
|
+
// automatic mirroring is still enabled on the connection.
|
|
770
|
+
if connection.automaticallyAdjustsVideoMirroring {
|
|
771
|
+
connection.automaticallyAdjustsVideoMirroring = false
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let shouldMirror = (type == "front")
|
|
775
|
+
if connection.isVideoMirrored != shouldMirror {
|
|
776
|
+
connection.isVideoMirrored = shouldMirror
|
|
662
777
|
}
|
|
663
778
|
}
|
|
664
779
|
}
|
|
@@ -749,28 +864,30 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
749
864
|
// Compute brightness early for OCR-B enhancement decision
|
|
750
865
|
let brightness = computeBrightness(from: pixelBuffer, width: portraitWidth, height: portraitHeight)
|
|
751
866
|
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
let textEnhancedImage = orientedImage
|
|
755
|
-
|
|
756
|
-
// Create VisionImage from the image (enhanced or original) for better text recognition
|
|
757
|
-
// This ensures MLKit processes the image in the correct orientation
|
|
758
|
-
guard let cgImage = self.ciContext.createCGImage(textEnhancedImage, from: textEnhancedImage.extent) else {
|
|
867
|
+
// Face detection / base64 use the full portrait frame.
|
|
868
|
+
guard let cgImage = self.ciContext.createCGImage(orientedImage, from: orientedImage.extent) else {
|
|
759
869
|
resetProcessingState()
|
|
760
870
|
return
|
|
761
871
|
}
|
|
762
872
|
let visionImage = VisionImage(image: UIImage(cgImage: cgImage))
|
|
763
873
|
visionImage.orientation = .up // Already oriented correctly
|
|
764
|
-
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
// -
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
874
|
+
|
|
875
|
+
// Text recognition: process ONLY the data inside the on-screen scan frame
|
|
876
|
+
// (matching Android). We crop the portrait frame to the scan-frame ROI
|
|
877
|
+
// (top 25% / bottom 12% / 4% side margins), UPSCALE it, and grayscale +
|
|
878
|
+
// contrast-enhance before OCR. This removes front-of-card text and glare
|
|
879
|
+
// noise and lifts a dense MRZ line above ML Kit's ~16px/char floor.
|
|
880
|
+
// `mrzRoi` carries the transform back to full portrait-frame coordinates.
|
|
881
|
+
let mrzRoi = enableTextRecognition
|
|
882
|
+
? buildScanFrameRoiImage(orientedImage, portraitWidth: portraitWidth, portraitHeight: portraitHeight)
|
|
883
|
+
: nil
|
|
884
|
+
var textVisionImage = visionImage
|
|
885
|
+
if let roi = mrzRoi,
|
|
886
|
+
let roiCg = self.ciContext.createCGImage(roi.image, from: roi.image.extent) {
|
|
887
|
+
let img = VisionImage(image: UIImage(cgImage: roiCg))
|
|
888
|
+
img.orientation = .up
|
|
889
|
+
textVisionImage = img
|
|
890
|
+
}
|
|
774
891
|
|
|
775
892
|
// Generate JPEG base64 only when JS side explicitly needs the image
|
|
776
893
|
// NOTE: Do NOT auto-generate for face detection - too expensive, causes frame drops
|
|
@@ -846,8 +963,16 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
846
963
|
let blockY: Int
|
|
847
964
|
let blockWidth: Int
|
|
848
965
|
let blockHeight: Int
|
|
849
|
-
|
|
850
|
-
if
|
|
966
|
+
|
|
967
|
+
if let roi = mrzRoi {
|
|
968
|
+
// OCR ran on the upscaled, already-portrait scan-frame ROI.
|
|
969
|
+
// Map the box back to full portrait-frame coordinates:
|
|
970
|
+
// divide out the ROI upscale, then add the ROI origin.
|
|
971
|
+
blockX = Int(bb.origin.x / roi.scale) + roi.offsetX
|
|
972
|
+
blockY = Int(bb.origin.y / roi.scale) + roi.offsetY
|
|
973
|
+
blockWidth = Int(bb.width / roi.scale)
|
|
974
|
+
blockHeight = Int(bb.height / roi.scale)
|
|
975
|
+
} else if isBufferLandscape {
|
|
851
976
|
// Rotate from landscape (1920x1080) to portrait (1080x1920)
|
|
852
977
|
// When rotating 90° clockwise (.right):
|
|
853
978
|
// new_x = old_y
|
|
@@ -926,6 +1051,69 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
|
926
1051
|
}
|
|
927
1052
|
}
|
|
928
1053
|
|
|
1054
|
+
/// A scan-frame ROI prepared for OCR, with the transform back to full-frame coords.
|
|
1055
|
+
private struct ScanFrameRoi {
|
|
1056
|
+
let image: CIImage
|
|
1057
|
+
let offsetX: Int
|
|
1058
|
+
let offsetY: Int
|
|
1059
|
+
let scale: CGFloat
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/// Crops the portrait frame to the on-screen scan frame (top 25% / bottom 12%
|
|
1063
|
+
/// / 4% side margins — the same rectangle the UI guide draws), UPSCALES it so a
|
|
1064
|
+
/// dense MRZ line clears ML Kit's ~16px/char floor, and grayscale + contrast
|
|
1065
|
+
/// enhances it (the MRZ is dark OCR-B on a light background, so this makes
|
|
1066
|
+
/// glyphs pop and suppresses the "<" filler being misread as a letter under
|
|
1067
|
+
/// glare). Returns nil on failure so the caller falls back to full-frame OCR.
|
|
1068
|
+
/// Mirrors the Android `buildScanFrameRoiImage`.
|
|
1069
|
+
private func buildScanFrameRoiImage(_ portrait: CIImage, portraitWidth: Int, portraitHeight: Int) -> ScanFrameRoi? {
|
|
1070
|
+
let w = portrait.extent.width
|
|
1071
|
+
let h = portrait.extent.height
|
|
1072
|
+
if w < 8 || h < 8 { return nil }
|
|
1073
|
+
|
|
1074
|
+
// Scan-frame rectangle. CIImage origin is BOTTOM-left, so "top 25%" maps to
|
|
1075
|
+
// a y measured from the bottom: cropY = h*0.12 (bottom margin), height =
|
|
1076
|
+
// h*(1 - 0.25 - 0.12).
|
|
1077
|
+
let leftPct: CGFloat = 0.04
|
|
1078
|
+
let rightPct: CGFloat = 0.04
|
|
1079
|
+
let topPct: CGFloat = 0.25
|
|
1080
|
+
let bottomPct: CGFloat = 0.12
|
|
1081
|
+
let cropX = (w * leftPct).rounded()
|
|
1082
|
+
let cropW = (w * (1 - leftPct - rightPct)).rounded()
|
|
1083
|
+
let cropY = (h * bottomPct).rounded()
|
|
1084
|
+
let cropH = (h * (1 - topPct - bottomPct)).rounded()
|
|
1085
|
+
if cropW < 8 || cropH < 8 { return nil }
|
|
1086
|
+
|
|
1087
|
+
let cropRect = CGRect(x: portrait.extent.origin.x + cropX,
|
|
1088
|
+
y: portrait.extent.origin.y + cropY,
|
|
1089
|
+
width: cropW, height: cropH)
|
|
1090
|
+
var img = portrait.cropped(to: cropRect)
|
|
1091
|
+
// Move the crop origin to (0,0) so downstream extents are simple.
|
|
1092
|
+
img = img.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
|
|
1093
|
+
|
|
1094
|
+
// Upscale (≤2.5×, long edge capped at 2200) for more pixels per character.
|
|
1095
|
+
let longEdge = max(cropW, cropH)
|
|
1096
|
+
let rawScale = longEdge > 0 ? (2200.0 / longEdge) : 1.0
|
|
1097
|
+
let scale = min(max(rawScale, 1.0), 2.5)
|
|
1098
|
+
if scale != 1.0 {
|
|
1099
|
+
img = img.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Grayscale (saturation 0) + contrast boost.
|
|
1103
|
+
img = img.applyingFilter("CIColorControls", parameters: [
|
|
1104
|
+
kCIInputSaturationKey: 0.0,
|
|
1105
|
+
kCIInputContrastKey: 1.6
|
|
1106
|
+
])
|
|
1107
|
+
|
|
1108
|
+
// The crop is taken from the TOP of the portrait frame (UIKit coords), but
|
|
1109
|
+
// CIImage y is bottom-up. The block coords ML Kit returns are top-left in
|
|
1110
|
+
// the OCR image, so the offset back to the portrait frame is the UIKit top
|
|
1111
|
+
// edge (= portraitHeight * topPct) and left edge.
|
|
1112
|
+
let offsetX = Int(cropX)
|
|
1113
|
+
let offsetY = Int((CGFloat(portraitHeight) * topPct).rounded())
|
|
1114
|
+
return ScanFrameRoi(image: img, offsetX: offsetX, offsetY: offsetY, scale: scale)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
929
1117
|
private func computeBrightness(from pixelBuffer: CVPixelBuffer, width: Int, height: Int) -> Double {
|
|
930
1118
|
guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else { return 128.0 }
|
|
931
1119
|
let pixelWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
|
|
@@ -1047,6 +1235,16 @@ extension TrustchexCameraView: AVCaptureFileOutputRecordingDelegate {
|
|
|
1047
1235
|
|
|
1048
1236
|
// Fire callback
|
|
1049
1237
|
if recordingSucceeded {
|
|
1238
|
+
let asset = AVURLAsset(url: outputFileURL)
|
|
1239
|
+
let hasAudioTrack = !asset.tracks(withMediaType: .audio).isEmpty
|
|
1240
|
+
|
|
1241
|
+
if !hasAudioTrack {
|
|
1242
|
+
DispatchQueue.main.async { [weak self] in
|
|
1243
|
+
self?.onRecordingError?(["error": "Recorded video has no audio track"])
|
|
1244
|
+
}
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1050
1248
|
DispatchQueue.main.async { [weak self] in
|
|
1051
1249
|
self?.onRecordingFinished?(["path": outputFileURL.path])
|
|
1052
1250
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
|
|
3
|
+
@interface RCT_EXTERN_MODULE(PermissionModule, NSObject)
|
|
4
|
+
|
|
5
|
+
RCT_EXTERN_METHOD(requestCameraPermission:(RCTPromiseResolveBlock)resolve
|
|
6
|
+
withRejecter:(RCTPromiseRejectBlock)reject)
|
|
7
|
+
|
|
8
|
+
RCT_EXTERN_METHOD(requestMicrophonePermission:(RCTPromiseResolveBlock)resolve
|
|
9
|
+
withRejecter:(RCTPromiseRejectBlock)reject)
|
|
10
|
+
|
|
11
|
+
RCT_EXTERN_METHOD(checkCameraPermission:(RCTPromiseResolveBlock)resolve
|
|
12
|
+
withRejecter:(RCTPromiseRejectBlock)reject)
|
|
13
|
+
|
|
14
|
+
RCT_EXTERN_METHOD(checkMicrophonePermission:(RCTPromiseResolveBlock)resolve
|
|
15
|
+
withRejecter:(RCTPromiseRejectBlock)reject)
|
|
16
|
+
|
|
17
|
+
+ (BOOL)requiresMainQueueSetup
|
|
18
|
+
{
|
|
19
|
+
return YES;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
@objc(PermissionModule)
|
|
5
|
+
class PermissionModule: NSObject {
|
|
6
|
+
|
|
7
|
+
@objc(requestCameraPermission:withRejecter:)
|
|
8
|
+
func requestCameraPermission(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
9
|
+
DispatchQueue.main.async {
|
|
10
|
+
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
|
11
|
+
|
|
12
|
+
switch status {
|
|
13
|
+
case .authorized:
|
|
14
|
+
resolve(true)
|
|
15
|
+
case .denied, .restricted:
|
|
16
|
+
resolve(false)
|
|
17
|
+
case .notDetermined:
|
|
18
|
+
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
19
|
+
resolve(granted)
|
|
20
|
+
}
|
|
21
|
+
@unknown default:
|
|
22
|
+
resolve(false)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@objc(requestMicrophonePermission:withRejecter:)
|
|
28
|
+
func requestMicrophonePermission(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
29
|
+
DispatchQueue.main.async {
|
|
30
|
+
let status = AVAudioSession.sharedInstance().recordPermission
|
|
31
|
+
|
|
32
|
+
switch status {
|
|
33
|
+
case .granted:
|
|
34
|
+
resolve(true)
|
|
35
|
+
case .denied:
|
|
36
|
+
resolve(false)
|
|
37
|
+
case .undetermined:
|
|
38
|
+
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
39
|
+
resolve(granted)
|
|
40
|
+
}
|
|
41
|
+
@unknown default:
|
|
42
|
+
resolve(false)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@objc(checkCameraPermission:withRejecter:)
|
|
48
|
+
func checkCameraPermission(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
49
|
+
DispatchQueue.main.async {
|
|
50
|
+
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
|
51
|
+
resolve(status == .authorized)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@objc(checkMicrophonePermission:withRejecter:)
|
|
56
|
+
func checkMicrophonePermission(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
57
|
+
DispatchQueue.main.async {
|
|
58
|
+
let status = AVAudioSession.sharedInstance().recordPermission
|
|
59
|
+
resolve(status == .granted)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@objc
|
|
64
|
+
static func requiresMainQueueSetup() -> Bool {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
}
|