@trustchex/react-native-sdk 1.464.0 → 1.472.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 (46) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +43 -3
  2. package/ios/Camera/TrustchexCameraView.swift +152 -27
  3. package/ios/Permission/PermissionModule.m +22 -0
  4. package/ios/Permission/PermissionModule.swift +67 -0
  5. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +24 -3
  6. package/lib/module/Screens/Dynamic/VerbalConsentScreen.js +1 -1
  7. package/lib/module/Screens/Dynamic/VideoCallScreen.js +7 -12
  8. package/lib/module/Screens/Static/ResultScreen.js +4 -1
  9. package/lib/module/Shared/Components/EIDScanner.js +166 -163
  10. package/lib/module/Shared/Components/FaceCamera.js +14 -8
  11. package/lib/module/Shared/Components/IdentityDocumentCamera.js +7 -6
  12. package/lib/module/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
  13. package/lib/module/Shared/Libs/index.js +20 -0
  14. package/lib/module/Shared/Libs/permissions.utils.js +199 -0
  15. package/lib/module/Translation/Resources/en.js +1 -0
  16. package/lib/module/Translation/Resources/tr.js +1 -0
  17. package/lib/module/version.js +1 -1
  18. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  19. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -1
  20. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  21. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Libs/index.d.ts +20 -0
  25. package/lib/typescript/src/Shared/Libs/index.d.ts.map +1 -0
  26. package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts +58 -0
  27. package/lib/typescript/src/Shared/Libs/permissions.utils.d.ts.map +1 -0
  28. package/lib/typescript/src/Translation/Resources/en.d.ts +1 -0
  29. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  30. package/lib/typescript/src/Translation/Resources/tr.d.ts +1 -0
  31. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  32. package/lib/typescript/src/version.d.ts +1 -1
  33. package/package.json +1 -1
  34. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +45 -5
  35. package/src/Screens/Dynamic/VerbalConsentScreen.tsx +1 -1
  36. package/src/Screens/Dynamic/VideoCallScreen.tsx +8 -19
  37. package/src/Screens/Static/ResultScreen.tsx +13 -9
  38. package/src/Shared/Components/EIDScanner.tsx +210 -160
  39. package/src/Shared/Components/FaceCamera.tsx +19 -16
  40. package/src/Shared/Components/IdentityDocumentCamera.tsx +7 -7
  41. package/src/Shared/Libs/PERMISSIONS_MANAGER.md +268 -0
  42. package/src/Shared/Libs/index.ts +63 -0
  43. package/src/Shared/Libs/permissions.utils.ts +251 -0
  44. package/src/Translation/Resources/en.ts +1 -0
  45. package/src/Translation/Resources/tr.ts +1 -0
  46. package/src/version.ts +1 -1
@@ -1,10 +1,13 @@
1
1
  package com.trustchex.reactnativesdk.camera
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.Manifest
5
+ import android.content.pm.PackageManager
4
6
  import android.graphics.Bitmap
5
7
  import android.graphics.BitmapFactory
6
8
  import android.graphics.ImageFormat
7
9
  import android.graphics.Matrix
10
+ import android.media.MediaMetadataRetriever
8
11
  import android.graphics.Rect
9
12
  import android.graphics.YuvImage
10
13
  import android.util.Base64
@@ -14,6 +17,8 @@ import android.util.Size
14
17
  import android.widget.FrameLayout
15
18
  import androidx.annotation.OptIn
16
19
  import androidx.camera.core.*
20
+ import androidx.camera.core.resolutionselector.ResolutionSelector
21
+ import androidx.camera.core.resolutionselector.ResolutionStrategy
17
22
  import androidx.camera.lifecycle.ProcessCameraProvider
18
23
  import androidx.camera.video.*
19
24
  import androidx.camera.view.PreviewView
@@ -303,10 +308,18 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
303
308
  "hd" -> Size(720, 1280) // Portrait HD
304
309
  else -> Size(1080, 1920) // Portrait Full HD (default)
305
310
  }
311
+ val resolutionSelector = ResolutionSelector.Builder()
312
+ .setResolutionStrategy(
313
+ ResolutionStrategy(
314
+ targetResolution,
315
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
316
+ )
317
+ )
318
+ .build()
306
319
 
307
320
  // Preview use case
308
321
  preview = Preview.Builder()
309
- .setTargetResolution(targetResolution)
322
+ .setResolutionSelector(resolutionSelector)
310
323
  .setTargetRotation(android.view.Surface.ROTATION_0) // Portrait
311
324
  .build()
312
325
  .also {
@@ -315,7 +328,7 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
315
328
 
316
329
  // Image analysis use case
317
330
  imageAnalyzer = ImageAnalysis.Builder()
318
- .setTargetResolution(targetResolution)
331
+ .setResolutionSelector(resolutionSelector)
319
332
  .setTargetRotation(android.view.Surface.ROTATION_0) // Portrait
320
333
  .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
321
334
  .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
@@ -777,6 +790,16 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
777
790
  return
778
791
  }
779
792
 
793
+ val hasMicPermission = ContextCompat.checkSelfPermission(
794
+ reactContext,
795
+ Manifest.permission.RECORD_AUDIO
796
+ ) == PackageManager.PERMISSION_GRANTED
797
+
798
+ if (!hasMicPermission) {
799
+ sendRecordingErrorEvent("Microphone permission is required for verbal consent recording")
800
+ return
801
+ }
802
+
780
803
  val videoCapture = videoCapture ?: run {
781
804
  sendRecordingErrorEvent("VideoCapture not initialized")
782
805
  return
@@ -813,7 +836,24 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
813
836
  sendRecordingErrorEvent("Recording error: ${event.cause?.message}")
814
837
  } else {
815
838
  val filePath = videoFile.absolutePath
816
- sendRecordingFinishedEvent(filePath)
839
+ val hasAudioTrack = try {
840
+ val retriever = MediaMetadataRetriever()
841
+ retriever.setDataSource(filePath)
842
+ val hasAudio = retriever
843
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
844
+ retriever.release()
845
+ hasAudio == "yes"
846
+ } catch (_: Exception) {
847
+ false
848
+ }
849
+
850
+ if (!hasAudioTrack) {
851
+ videoFile.delete()
852
+ currentRecordingFile = null
853
+ sendRecordingErrorEvent("Recorded video has no audio track")
854
+ } else {
855
+ sendRecordingFinishedEvent(filePath)
856
+ }
817
857
  }
818
858
  }
819
859
  else -> {}
@@ -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
- // Prefer the widest front camera available for better face framing
241
- let discovery = AVCaptureDevice.DiscoverySession(
242
- deviceTypes: [.builtInUltraWideCamera, .builtInWideAngleCamera],
243
- mediaType: .video,
244
- position: .front
245
- )
246
- // DiscoverySession lists in the order of deviceTypes, so ultra-wide comes first if available
247
- return discovery.devices.first
248
- ?? AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
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)
293
+ }
294
+
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
+ }
249
311
  }
250
312
 
251
- // For document scanning, prefer wide angle camera for all models
252
- // This provides consistent behavior across iPhone 15, 15 Pro, and other devices
253
- if let wideAngleCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
254
- return wideAngleCamera
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
255
326
  }
256
327
 
257
- // Fallback: Try dual camera
258
- if let dualCamera = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back) {
259
- return dualCamera
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
260
331
  }
261
332
 
262
- // Fallback: Try triple camera
263
- if let tripleCamera = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) {
264
- return tripleCamera
333
+ // Fallback: triple-camera system.
334
+ if let cam = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) {
335
+ return cam
265
336
  }
266
337
 
267
- // Final fallback: any back camera
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 connection exists
499
- guard movieOutput.connection(with: .video) != nil else { return }
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).mp4"
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
- if let connection = self?.previewLayer?.connection {
659
- if connection.isVideoMirroringSupported {
660
- connection.isVideoMirrored = (type == "front")
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
  }
@@ -1047,6 +1162,16 @@ extension TrustchexCameraView: AVCaptureFileOutputRecordingDelegate {
1047
1162
 
1048
1163
  // Fire callback
1049
1164
  if recordingSucceeded {
1165
+ let asset = AVURLAsset(url: outputFileURL)
1166
+ let hasAudioTrack = !asset.tracks(withMediaType: .audio).isEmpty
1167
+
1168
+ if !hasAudioTrack {
1169
+ DispatchQueue.main.async { [weak self] in
1170
+ self?.onRecordingError?(["error": "Recorded video has no audio track"])
1171
+ }
1172
+ return
1173
+ }
1174
+
1050
1175
  DispatchQueue.main.async { [weak self] in
1051
1176
  self?.onRecordingFinished?(["path": outputFileURL.path])
1052
1177
  }
@@ -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
+ }
@@ -117,7 +117,6 @@ const LivenessDetectionScreen = () => {
117
117
  instruction: t('livenessDetectionScreen.finish')
118
118
  }
119
119
  });
120
- const [instructionList, setInstructionList] = useState([]);
121
120
  const [hasGuideShown, setHasGuideShown] = useState(false);
122
121
  const stoppingRecordingRef = useRef(false); // Track if we're already stopping to prevent multiple calls
123
122
 
@@ -143,7 +142,6 @@ const LivenessDetectionScreen = () => {
143
142
  instruction === 'LOOK_STRAIGHT_AND_BLINK' || !appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.length === 0 || appContext.currentWorkflowStep?.data?.allowedLivenessInstructionTypes.includes(instruction))).sort(() => Math.random() - 0.5);
144
143
  il.unshift('START');
145
144
  il.push('FINISH');
146
- setInstructionList(il);
147
145
  setInitialState({
148
146
  brightnessLow: false,
149
147
  faceDetected: false,
@@ -440,7 +438,30 @@ const LivenessDetectionScreen = () => {
440
438
  const circleR = previewSizeInFrame / 2;
441
439
  const faceDx = faceCenterX - circleCX;
442
440
  const faceDy = faceCenterY - circleCY;
443
- const previewContainsFace = faceDx * faceDx + faceDy * faceDy <= (circleR - faceCoreRadius) * (circleR - faceCoreRadius);
441
+ const yawAbs = Math.abs(face.yawAngle || 0);
442
+ const yawFactor = Math.min(1, yawAbs / TURN_ANGLE_LIMIT);
443
+
444
+ // General bound tolerance (instruction-agnostic): adapt to head pose and
445
+ // face box deformation so containment remains stable across all actions.
446
+ const faceWidthGrowth = Math.max(0, face.bounds.width - face.bounds.height);
447
+
448
+ // On iOS, close faces tend to appear larger in-frame and lateral head
449
+ // turns shift center more aggressively. Apply an iOS-only proximity boost
450
+ // to horizontal tolerance to avoid false outside-circle resets.
451
+ const faceRadiusRatio = Math.min(1, faceCoreRadius / Math.max(1, circleR));
452
+ const iosProximityBoost = Platform.OS === 'ios' ? Math.max(0, faceRadiusRatio - 0.5) : 0;
453
+ const baseToleranceX = previewSizeInFrame * 0.05;
454
+ const yawToleranceX = face.bounds.width * 0.12 * yawFactor;
455
+ const shapeToleranceX = faceWidthGrowth * 0.1;
456
+ const iosToleranceX = face.bounds.width * 0.2 * iosProximityBoost;
457
+ const toleranceX = Math.max(0, baseToleranceX + yawToleranceX + shapeToleranceX + iosToleranceX, face.bounds.width * 0.05);
458
+ const pitchAbs = Math.abs(face.pitchAngle || 0);
459
+ const pitchFactor = Math.min(1, pitchAbs / TURN_ANGLE_LIMIT);
460
+ const toleranceY = Math.max(0, previewSizeInFrame * 0.03 + face.bounds.height * 0.03 * pitchFactor, face.bounds.height * 0.04);
461
+ const allowedRadiusX = Math.max(1, circleR - faceCoreRadius + toleranceX);
462
+ const allowedRadiusY = Math.max(1, circleR - faceCoreRadius + toleranceY);
463
+ const normalizedDistance = faceDx * faceDx / (allowedRadiusX * allowedRadiusX) + faceDy * faceDy / (allowedRadiusY * allowedRadiusY);
464
+ const previewContainsFace = normalizedDistance <= 1;
444
465
  const multipleFacesDetected = faces.length > 1;
445
466
  if (!isImageBright) {
446
467
  // Brightness too low - reset progress if we've started the flow
@@ -575,7 +575,7 @@ const VerbalConsentScreen = () => {
575
575
  if (!currentInstructionKey) {
576
576
  return;
577
577
  }
578
- speak(t(currentInstructionKey));
578
+ speak(t(currentInstructionKey)).catch(() => {});
579
579
  }, [appContext.currentWorkflowStep?.data?.voiceGuidanceActive, currentInstructionKey, hasGuideShown, t]);
580
580
  useEffect(() => {
581
581
  return () => {
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  import React, { useEffect, useState, useRef, useContext } from 'react';
4
- import { Dimensions, Platform, StyleSheet, Text, TouchableOpacity, View, ActivityIndicator, PermissionsAndroid, StatusBar } from 'react-native';
4
+ import { Dimensions, Platform, StyleSheet, Text, TouchableOpacity, View, ActivityIndicator, StatusBar } from 'react-native';
5
5
  import { RTCView } from 'react-native-webrtc';
6
6
  import InCallManager from 'react-native-incall-manager';
7
7
  import LottieView from 'lottie-react-native';
8
8
  import { useTranslation } from 'react-i18next';
9
+ import PermissionManager from "../../Shared/Libs/permissions.utils.js";
9
10
  import { useStatusBarWhiteBackground } from "../../Shared/Libs/status-bar.utils.js";
10
11
  import { WebRTCService } from "../../Shared/Services/WebRTCService.js";
11
12
  import { VideoSessionService } from "../../Shared/Services/VideoSessionService.js";
@@ -316,18 +317,12 @@ const VideoCallScreen = ({
316
317
  });
317
318
  serviceRef.current = service;
318
319
 
319
- // Request Permissions
320
- if (Platform.OS === 'android') {
321
- const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.CAMERA, PermissionsAndroid.PERMISSIONS.RECORD_AUDIO]);
322
- if (granted['android.permission.CAMERA'] !== PermissionsAndroid.RESULTS.GRANTED || granted['android.permission.RECORD_AUDIO'] !== PermissionsAndroid.RESULTS.GRANTED) {
323
- if (mounted) setConnectionState('permissions_denied');
324
- return;
325
- }
320
+ // Request Camera and Microphone Permissions
321
+ const hasBothPermissions = await PermissionManager.requestCameraAndMicrophone();
322
+ if (!hasBothPermissions) {
323
+ if (mounted) setConnectionState('permissions_denied');
324
+ return;
326
325
  }
327
- // For iOS, react-native-webrtc or VisionCamera usually triggers it,
328
- // but explicit request via library is better if we had one.
329
- // Assuming automatic or previously granted for now.
330
-
331
326
  try {
332
327
  const stream = await service.initialize();
333
328
  if (mounted) {
@@ -695,7 +695,7 @@ const ResultScreen = () => {
695
695
  }), /*#__PURE__*/_jsx(Text, {
696
696
  style: styles.sectionText,
697
697
  children: t('resultScreen.demoVideo')
698
- }), /*#__PURE__*/_jsx(Video, {
698
+ }), consentVideo.videoPath ? /*#__PURE__*/_jsx(Video, {
699
699
  source: {
700
700
  uri: consentVideo.videoPath
701
701
  },
@@ -703,6 +703,9 @@ const ResultScreen = () => {
703
703
  style: styles.video,
704
704
  controls: true,
705
705
  muted: false
706
+ }) : /*#__PURE__*/_jsx(Text, {
707
+ style: styles.sectionText,
708
+ children: "N/A"
706
709
  })]
707
710
  }, `${consentVideo.videoPath}-${index}`))]
708
711
  })]