@myazahq/kyc-sdk-react-native 2.0.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 (87) hide show
  1. package/KycSdkReactNative.podspec +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +164 -0
  4. package/android/CMakeLists.txt +29 -0
  5. package/android/build.gradle +110 -0
  6. package/android/src/main/AndroidManifest.xml +12 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +12 -0
  8. package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
  9. package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
  10. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
  11. package/app.plugin.js +55 -0
  12. package/expo-module.config.json +6 -0
  13. package/ios/HybridMyazaFaceDetector.swift +233 -0
  14. package/package.json +84 -0
  15. package/react-native.config.js +23 -0
  16. package/src/MyazaKYC.tsx +235 -0
  17. package/src/__tests__/cardCrop.test.ts +39 -0
  18. package/src/__tests__/deviceMetadata.test.ts +34 -0
  19. package/src/__tests__/errors.test.ts +34 -0
  20. package/src/__tests__/flow.test.ts +61 -0
  21. package/src/__tests__/gestureDetector.test.ts +37 -0
  22. package/src/__tests__/liveness.test.ts +112 -0
  23. package/src/__tests__/resolveUrl.test.ts +64 -0
  24. package/src/__tests__/validators.test.ts +38 -0
  25. package/src/assets/liveness/Blink.gif +0 -0
  26. package/src/assets/liveness/Nod.gif +0 -0
  27. package/src/assets/liveness/Smile.gif +0 -0
  28. package/src/assets/liveness/Turn.gif +0 -0
  29. package/src/components/CameraPermissionView.tsx +116 -0
  30. package/src/components/CameraViewfinder.tsx +156 -0
  31. package/src/components/CountryFlag.tsx +52 -0
  32. package/src/components/DocumentCropper.tsx +325 -0
  33. package/src/components/GlassIconButton.tsx +92 -0
  34. package/src/components/Icon.tsx +125 -0
  35. package/src/components/KycFlow.tsx +205 -0
  36. package/src/components/KycSheet.tsx +224 -0
  37. package/src/components/MyazaAlert.tsx +57 -0
  38. package/src/components/MyazaButton.tsx +101 -0
  39. package/src/components/MyazaCard.tsx +48 -0
  40. package/src/components/MyazaInput.tsx +112 -0
  41. package/src/components/MyazaPulseLoader.tsx +71 -0
  42. package/src/components/StatusBarController.tsx +42 -0
  43. package/src/components/StepHeader.tsx +56 -0
  44. package/src/components/StepIndicator.tsx +74 -0
  45. package/src/components/Typography.tsx +69 -0
  46. package/src/components/fonts.ts +49 -0
  47. package/src/components/glass/GlassGroup.tsx +34 -0
  48. package/src/components/glass/GlassSurface.tsx +64 -0
  49. package/src/components/runtime.tsx +93 -0
  50. package/src/components/toast.tsx +154 -0
  51. package/src/components/useBranding.ts +27 -0
  52. package/src/components/useVideoRecorder.ts +122 -0
  53. package/src/config/captureSettings.ts +67 -0
  54. package/src/config/idTypes.ts +79 -0
  55. package/src/config/theme.ts +186 -0
  56. package/src/index.ts +54 -0
  57. package/src/liveness/challengeManager.ts +130 -0
  58. package/src/liveness/faceDetector.ts +79 -0
  59. package/src/liveness/gestureDetector.ts +80 -0
  60. package/src/liveness/speech.ts +66 -0
  61. package/src/liveness/types.ts +99 -0
  62. package/src/liveness/useLiveness.ts +484 -0
  63. package/src/liveness/visionCameraFaceDetector.ts +118 -0
  64. package/src/screens/ConsentStep.tsx +164 -0
  65. package/src/screens/DocumentCaptureStep.tsx +500 -0
  66. package/src/screens/IdInputStep.tsx +79 -0
  67. package/src/screens/IdTypeStep.tsx +142 -0
  68. package/src/screens/LivenessAvatar.tsx +69 -0
  69. package/src/screens/LivenessStep.tsx +615 -0
  70. package/src/screens/SubmittedStep.tsx +177 -0
  71. package/src/services/api.ts +291 -0
  72. package/src/services/cardCrop.ts +52 -0
  73. package/src/services/deviceMetadata.ts +185 -0
  74. package/src/services/errors.ts +92 -0
  75. package/src/services/mediaCompress.ts +129 -0
  76. package/src/services/resolveUrl.ts +98 -0
  77. package/src/services/retry.ts +70 -0
  78. package/src/services/validators.ts +103 -0
  79. package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
  80. package/src/store/kycStore.ts +288 -0
  81. package/src/store/serverConfig.ts +78 -0
  82. package/src/types/config.ts +239 -0
  83. package/src/types/country-flag-icons.d.ts +6 -0
  84. package/src/types/verification.ts +79 -0
  85. package/src/utils/platform.ts +23 -0
  86. package/src/utils/tokens.ts +10 -0
  87. package/src/utils/uuid.ts +31 -0
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["ios"],
3
+ "ios": {
4
+ "podName": "KycSdkReactNative"
5
+ }
6
+ }
@@ -0,0 +1,233 @@
1
+ import Foundation
2
+ import Vision
3
+ import CoreMedia
4
+ import CoreVideo
5
+ import VisionCamera
6
+ import NitroModules
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // HybridMyazaFaceDetector — the iOS half of the Myaza KYC face detector, now a
10
+ // VisionCamera v5 **Nitro** HybridObject (the v4 FrameProcessorPlugin +
11
+ // VISION_EXPORT_SWIFT_FRAME_PROCESSOR macro are gone). Implements the nitrogen-
12
+ // generated `HybridMyazaFaceDetectorSpec` from src/specs/MyazaFaceDetector.nitro.ts.
13
+ // The Android half is HybridMyazaFaceDetector.kt (Google ML Kit). Both return the
14
+ // SAME `FaceResult` shape the TS liveness flow consumes (see liveness/types.ts):
15
+ //
16
+ // headEulerAngleX/Y/Z, smilingProbability,
17
+ // leftEyeOpenProbability, rightEyeOpenProbability,
18
+ // faceSizeRatio, faceCount (faceCount == 0 means "no face")
19
+ //
20
+ // Direct port of the old v4 plugin's Apple Vision logic. The frame's CVPixelBuffer
21
+ // is reached by casting `any HybridFrameSpec` to VisionCamera's public
22
+ // `NativeFrame` protocol (→ `sampleBuffer` → image buffer).
23
+ //
24
+ // Heuristic note (unchanged from v4 — tune on a physical iPhone): yaw/pitch/roll
25
+ // come from Vision; eye-open and smile are derived from landmark geometry and
26
+ // mapped to ML Kit's 0–1 scale; the `.upMirrored` orientation and the nod pitch
27
+ // sign are conservative starting points.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
31
+ func detectFace(frame: any HybridFrameSpec) throws -> FaceResult {
32
+ guard
33
+ let nativeFrame = frame as? NativeFrame,
34
+ let sampleBuffer = nativeFrame.sampleBuffer,
35
+ let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
36
+ else {
37
+ return Self.noFace(brightness: 128)
38
+ }
39
+
40
+ // Mean frame luma (0–255) for the low-light gate — computed every frame,
41
+ // independent of whether a face is found, so the dark/bright warning shows
42
+ // even before the user is in frame.
43
+ let brightness = Self.averageLuma(pixelBuffer)
44
+
45
+ // Front camera, portrait. VisionCamera v5 delivers the buffer rotated 90°
46
+ // vs the v4/Flutter pipeline: `.upMirrored` here gives roll ≈ −105° (face
47
+ // sideways), which scrambles the landmark-derived smile/eye/pitch signals.
48
+ // `.leftMirrored` adds the missing +90°-family rotation so the face lands
49
+ // upright (roll ≈ 0) while keeping the front-camera mirror (yaw stays
50
+ // correct). Tune on device if roll is still off.
51
+ let orientation: CGImagePropertyOrientation = .leftMirrored
52
+
53
+ let request = VNDetectFaceLandmarksRequest()
54
+ let handler = VNImageRequestHandler(
55
+ cvPixelBuffer: pixelBuffer, orientation: orientation, options: [:]
56
+ )
57
+
58
+ do {
59
+ try handler.perform([request])
60
+ } catch {
61
+ return Self.noFace(brightness: brightness)
62
+ }
63
+
64
+ guard
65
+ let faces = request.results, !faces.isEmpty,
66
+ let face = faces.max(by: {
67
+ ($0.boundingBox.width * $0.boundingBox.height)
68
+ < ($1.boundingBox.width * $1.boundingBox.height)
69
+ })
70
+ else {
71
+ return Self.noFace(brightness: brightness)
72
+ }
73
+
74
+ let toDeg = 180.0 / Double.pi
75
+ let yawDeg = (face.yaw?.doubleValue ?? 0) * toDeg
76
+ let rollDeg = (face.roll?.doubleValue ?? 0) * toDeg
77
+
78
+ // Pitch: Vision's `face.pitch` is iOS 15+ and frequently returns nil/0 for
79
+ // VNDetectFaceLandmarksRequest. Derive a proxy from landmark geometry when
80
+ // it does — looking down pushes the nose UP in normalized landmark space, so
81
+ // we negate to match ML Kit's convention (headEulerAngleX < 0 == down).
82
+ var pitchDeg = 0.0
83
+ if #available(iOS 15.0, *), let p = face.pitch?.doubleValue, p != 0 {
84
+ pitchDeg = p * toDeg
85
+ } else {
86
+ pitchDeg = Self.pitchFromLandmarks(face.landmarks)
87
+ }
88
+
89
+ let leftOpen = Self.openProbability(fromEAR: Self.eyeAspectRatio(face.landmarks?.leftEye))
90
+ let rightOpen = Self.openProbability(fromEAR: Self.eyeAspectRatio(face.landmarks?.rightEye))
91
+ let smile = Self.smileProbability(face.landmarks)
92
+ let faceSizeRatio = Double(face.boundingBox.width)
93
+
94
+ return FaceResult(
95
+ headEulerAngleX: pitchDeg,
96
+ headEulerAngleY: yawDeg,
97
+ headEulerAngleZ: rollDeg,
98
+ smilingProbability: smile,
99
+ leftEyeOpenProbability: leftOpen,
100
+ rightEyeOpenProbability: rightOpen,
101
+ faceSizeRatio: faceSizeRatio,
102
+ // Number of faces in frame — the TS liveness flow pauses on > 1.
103
+ faceCount: Double(faces.count),
104
+ brightness: brightness
105
+ )
106
+ }
107
+
108
+ // "No face" sentinel — faceCount 0 (the worklet maps this to onNoFace), but
109
+ // still carries the frame brightness so the low-light gate works pre-face.
110
+ private static func noFace(brightness: Double) -> FaceResult {
111
+ FaceResult(
112
+ headEulerAngleX: 0, headEulerAngleY: 0, headEulerAngleZ: 0,
113
+ smilingProbability: 0, leftEyeOpenProbability: 1, rightEyeOpenProbability: 1,
114
+ faceSizeRatio: 0, faceCount: 0, brightness: brightness
115
+ )
116
+ }
117
+
118
+ /// Mean luma (0–255) of the frame, sampled on a coarse ~64×48 grid for speed.
119
+ /// For a YUV (4:2:0) buffer the first plane IS luma; for non-planar buffers we
120
+ /// approximate from the green channel. Mirrors the Flutter `_BrightnessSampler`.
121
+ private static func averageLuma(_ pixelBuffer: CVPixelBuffer) -> Double {
122
+ CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
123
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
124
+
125
+ if CVPixelBufferGetPlaneCount(pixelBuffer) > 0,
126
+ let base = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) {
127
+ let w = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
128
+ let h = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
129
+ let rowBytes = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
130
+ let ptr = base.assumingMemoryBound(to: UInt8.self)
131
+ let stepX = max(1, w / 64)
132
+ let stepY = max(1, h / 48)
133
+ var sum = 0.0
134
+ var count = 0
135
+ var y = 0
136
+ while y < h {
137
+ let row = ptr + y * rowBytes
138
+ var x = 0
139
+ while x < w {
140
+ sum += Double(row[x])
141
+ count += 1
142
+ x += stepX
143
+ }
144
+ y += stepY
145
+ }
146
+ return count > 0 ? sum / Double(count) : 128
147
+ }
148
+
149
+ // Non-planar (e.g. BGRA) fallback — sample the green byte as a luma proxy.
150
+ guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return 128 }
151
+ let w = CVPixelBufferGetWidth(pixelBuffer)
152
+ let h = CVPixelBufferGetHeight(pixelBuffer)
153
+ let rowBytes = CVPixelBufferGetBytesPerRow(pixelBuffer)
154
+ let ptr = base.assumingMemoryBound(to: UInt8.self)
155
+ let stepX = max(1, w / 64)
156
+ let stepY = max(1, h / 48)
157
+ var sum = 0.0
158
+ var count = 0
159
+ var y = 0
160
+ while y < h {
161
+ let row = ptr + y * rowBytes
162
+ var x = 0
163
+ while x < w {
164
+ sum += Double(row[x * 4 + 1]) // BGRA → green
165
+ count += 1
166
+ x += stepX
167
+ }
168
+ y += stepY
169
+ }
170
+ return count > 0 ? sum / Double(count) : 128
171
+ }
172
+
173
+ // MARK: - Landmark heuristics (tune on device)
174
+
175
+ private static func eyeAspectRatio(_ region: VNFaceLandmarkRegion2D?) -> Double {
176
+ guard let pts = region?.normalizedPoints, pts.count >= 4 else { return 0.3 }
177
+ var minX = Double.greatestFiniteMagnitude
178
+ var maxX = -Double.greatestFiniteMagnitude
179
+ var minY = Double.greatestFiniteMagnitude
180
+ var maxY = -Double.greatestFiniteMagnitude
181
+ for p in pts {
182
+ minX = min(minX, Double(p.x)); maxX = max(maxX, Double(p.x))
183
+ minY = min(minY, Double(p.y)); maxY = max(maxY, Double(p.y))
184
+ }
185
+ let w = maxX - minX
186
+ let h = maxY - minY
187
+ return w > 0 ? h / w : 0.3
188
+ }
189
+
190
+ /// EAR → eye-open probability. EAR ≈ 0.10 (closed) → 0, ≈ 0.30 (open) → 1.
191
+ private static func openProbability(fromEAR ear: Double) -> Double {
192
+ let v = (ear - 0.10) / (0.30 - 0.10)
193
+ return min(max(v, 0.0), 1.0)
194
+ }
195
+
196
+ /// Smile from outer-lip width relative to the face. Input band (0.40→0.50):
197
+ /// a neutral mouth sits near 0, a clear smile lands well above the threshold.
198
+ private static func smileProbability(_ landmarks: VNFaceLandmarks2D?) -> Double {
199
+ guard let pts = landmarks?.outerLips?.normalizedPoints, pts.count >= 4 else {
200
+ return 0.0
201
+ }
202
+ var minX = Double.greatestFiniteMagnitude
203
+ var maxX = -Double.greatestFiniteMagnitude
204
+ for p in pts {
205
+ minX = min(minX, Double(p.x)); maxX = max(maxX, Double(p.x))
206
+ }
207
+ let width = maxX - minX
208
+ let score = (width - 0.40) / (0.50 - 0.40)
209
+ return min(max(score, 0.0), 1.0)
210
+ }
211
+
212
+ /// Pitch (nod) proxy from landmark geometry, used when Vision's `face.pitch`
213
+ /// is unavailable. Compares the nose vertical position to the eye line in the
214
+ /// face's normalized landmark space (origin bottom-left, y up). Looking DOWN
215
+ /// shrinks the eye→nose gap; mapped to a signed pseudo-degree, negated so down
216
+ /// yields a negative angle — matching ML Kit's headEulerAngleX convention.
217
+ private static func pitchFromLandmarks(_ landmarks: VNFaceLandmarks2D?) -> Double {
218
+ guard let lm = landmarks else { return 0 }
219
+ func meanY(_ r: VNFaceLandmarkRegion2D?) -> Double? {
220
+ guard let pts = r?.normalizedPoints, !pts.isEmpty else { return nil }
221
+ return pts.reduce(0.0) { $0 + Double($1.y) } / Double(pts.count)
222
+ }
223
+ let noseY = meanY(lm.noseCrest) ?? meanY(lm.nose)
224
+ let leftEyeY = meanY(lm.leftEye)
225
+ let rightEyeY = meanY(lm.rightEye)
226
+ guard let n = noseY, let le = leftEyeY, let re = rightEyeY else { return 0 }
227
+ let eyeY = (le + re) / 2.0
228
+ let gap = eyeY - n
229
+ let neutralGap = 0.18
230
+ let degPerUnit = 220.0
231
+ return (gap - neutralGap) * degPerUnit
232
+ }
233
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@myazahq/kyc-sdk-react-native",
3
+ "version": "2.0.0",
4
+ "description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/myazahq/kyc-sdk-react-native.git"
8
+ },
9
+ "homepage": "https://github.com/myazahq/kyc-sdk-react-native#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/myazahq/kyc-sdk-react-native/issues"
12
+ },
13
+ "main": "src/index.ts",
14
+ "types": "src/index.ts",
15
+ "files": [
16
+ "src",
17
+ "android",
18
+ "ios",
19
+ "app.plugin.js",
20
+ "expo-module.config.json",
21
+ "react-native.config.js",
22
+ "KycSdkReactNative.podspec"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "jest",
30
+ "specs": "nitrogen"
31
+ },
32
+ "keywords": [
33
+ "kyc",
34
+ "identity",
35
+ "verification",
36
+ "liveness",
37
+ "expo",
38
+ "react-native"
39
+ ],
40
+ "license": "MIT",
41
+ "peerDependencies": {
42
+ "expo": ">=56",
43
+ "react": ">=19",
44
+ "react-native": ">=0.83",
45
+ "react-native-nitro-image": ">=0.15",
46
+ "react-native-nitro-modules": ">=0.35",
47
+ "react-native-safe-area-context": ">=4",
48
+ "react-native-svg": ">=15",
49
+ "react-native-vision-camera-worklets": ">=5",
50
+ "react-native-worklets": ">=0.8"
51
+ },
52
+ "dependencies": {
53
+ "@expo-google-fonts/karla": "^0.4.2",
54
+ "@expo-google-fonts/space-grotesk": "^0.4.1",
55
+ "@expo/ui": "~56.0.16",
56
+ "@expo/vector-icons": "^14.0.4",
57
+ "country-flag-icons": "^1.6.17",
58
+ "expo-application": "~56.0.3",
59
+ "expo-crypto": "~56.0.4",
60
+ "expo-device": "~56.0.4",
61
+ "expo-font": "~56.0.5",
62
+ "expo-glass-effect": "~56.0.4",
63
+ "expo-image-manipulator": "~56.0.17",
64
+ "expo-image-picker": "~56.0.16",
65
+ "expo-localization": "~56.0.6",
66
+ "expo-speech": "~56.0.3",
67
+ "lucide-react-native": "^1.17.0",
68
+ "react-native-compressor": "^1.19.0",
69
+ "react-native-vision-camera": "^5.0.11",
70
+ "zustand": "^5.0.2"
71
+ },
72
+ "devDependencies": {
73
+ "@types/jest": "^29.5.12",
74
+ "@types/node": "^22.10.2",
75
+ "@types/react": "~19.2.2",
76
+ "expo": "~56.0.9",
77
+ "jest": "^29.7.0",
78
+ "nitrogen": "0.35.9",
79
+ "react": "19.2.3",
80
+ "react-native": "0.85.3",
81
+ "ts-jest": "^29.2.5",
82
+ "typescript": "^5.6.3"
83
+ }
84
+ }
@@ -0,0 +1,23 @@
1
+ // React Native CLI autolinking config.
2
+ //
3
+ // The Android side of this SDK is a plain RN native module that exposes a
4
+ // `ReactPackage` (`MyazaFaceDetectorPackage`, which registers the VisionCamera
5
+ // "detectFace" frame-processor plugin). RN CLI autolinking — NOT Expo modules
6
+ // autolinking — is what discovers `ReactPackage`s, so the Android project is
7
+ // declared here. (Expo's `expo-module.config.json` is iOS-only for this package;
8
+ // a `ReactPackage` is not an Expo `Module` and can't be registered there.)
9
+ //
10
+ // iOS autolinking finds the pod via the `*.podspec` at the package root, so no
11
+ // iOS entry is needed here.
12
+
13
+ module.exports = {
14
+ dependency: {
15
+ platforms: {
16
+ android: {
17
+ sourceDir: 'android',
18
+ packageImportPath: 'import co.myazahq.kyc.rn.MyazaFaceDetectorPackage;',
19
+ packageInstance: 'new MyazaFaceDetectorPackage()',
20
+ },
21
+ },
22
+ },
23
+ };
@@ -0,0 +1,235 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { Modal, Platform } from 'react-native';
3
+
4
+ // iOS presents the flow as a swipe-down card sheet (the RN equivalent of
5
+ // Flutter's showModalBottomSheet); Android uses a full-screen page (matching
6
+ // Flutter's Android MaterialPageRoute).
7
+ const MODAL_PRESENTATION = Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen';
8
+
9
+ // 'slide' drives the present animation: on iOS the native pageSheet slides up and
10
+ // keeps its native drag-to-dismiss; on Android the full-screen page slides up.
11
+ // ('none' suppresses the present transition, leaving no slide-up and no drag.)
12
+ const MODAL_ANIMATION = 'slide';
13
+
14
+ import type { KYCStep, MyazaKYCConfig, SupportedCountry } from './types/config';
15
+ import { KycRuntimeProvider, useKycStore } from './components/runtime';
16
+ import { KycFlow, type BackResult } from './components/KycFlow';
17
+ import { MyazaButton } from './components/MyazaButton';
18
+
19
+ /**
20
+ * Builds the `<Modal onRequestClose>` handler (Android hardware back / TV menu).
21
+ * It first asks the flow's back handler (via `backRef`) to navigate a step back;
22
+ * only when the flow is at its first step ('close') and close isn't disabled
23
+ * does it actually close. This is why back walks the flow backward instead of
24
+ * dismissing the whole SDK. Falls back to `close()` if the flow hasn't wired a
25
+ * handler yet (e.g. before first paint).
26
+ */
27
+ function makeRequestClose(
28
+ backRef: React.MutableRefObject<(() => BackResult) | null>,
29
+ close: () => void,
30
+ blockDismiss: boolean,
31
+ ): () => void {
32
+ return () => {
33
+ const result = backRef.current?.();
34
+ if (result === 'navigated' || result === 'blocked') return;
35
+ // result === 'close' (first step, close allowed) or no handler yet.
36
+ if (!blockDismiss) close();
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Public entry points — `<MyazaKYC />` (trigger + modal) and `useMyazaKYC()`
42
+ // (custom trigger). Mirrors the web SDK's component + hook API, adapted to React
43
+ // Native's `<Modal>` (the RN equivalent of the web modal/portal).
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export interface MyazaKYCProps<C extends SupportedCountry = SupportedCountry> extends MyazaKYCConfig<C> {
47
+ /** Custom trigger label (defaults to "Verify with {companyName}" / "Verify Identity"). */
48
+ children?: string;
49
+ /** Disable the built-in trigger button. */
50
+ disabled?: boolean;
51
+ }
52
+
53
+ function defaultTriggerLabel(config: MyazaKYCConfig): string {
54
+ const name = config.appearance?.companyName;
55
+ return name ? `Verify with ${name}` : 'Verify Identity';
56
+ }
57
+
58
+ export function MyazaKYC<C extends SupportedCountry = SupportedCountry>(
59
+ props: MyazaKYCProps<C>,
60
+ ): React.ReactElement {
61
+ const { children, disabled, ...config } = props;
62
+ // The provider wraps BOTH the trigger and the modal so the built-in trigger
63
+ // button can read the theme. The store is created once and `reset()` on each
64
+ // open for a fresh run.
65
+ return (
66
+ <KycRuntimeProvider config={config}>
67
+ <MyazaKYCTrigger config={config} label={children} disabled={disabled} />
68
+ </KycRuntimeProvider>
69
+ );
70
+ }
71
+
72
+ function MyazaKYCTrigger({
73
+ config,
74
+ label,
75
+ disabled,
76
+ }: {
77
+ config: MyazaKYCConfig;
78
+ label?: string;
79
+ disabled?: boolean;
80
+ }): React.ReactElement {
81
+ const store = useKycStore();
82
+ const [open, setOpen] = useState(false);
83
+ // Idempotent close — the X button, Android back, and iOS swipe-down dismiss can
84
+ // each fire; `onClose` must run at most once per open.
85
+ const closedRef = useRef(true);
86
+ const backRef = useRef<(() => BackResult) | null>(null);
87
+
88
+ const openFlow = useCallback(() => {
89
+ closedRef.current = false;
90
+ store.getState().reset();
91
+ setOpen(true);
92
+ }, [store]);
93
+
94
+ const close = useCallback(() => {
95
+ if (closedRef.current) return;
96
+ closedRef.current = true;
97
+ setOpen(false);
98
+ config.onClose?.();
99
+ }, [config]);
100
+
101
+ // `disableClose` presents full-screen on iOS (kills the swipe-down drag). The
102
+ // Android hardware back goes through `onRequestClose`, which walks the flow
103
+ // back a step (or closes from the first step, unless disableClose).
104
+ const blockDismiss = config.disableClose === true;
105
+ const presentation = blockDismiss ? 'fullScreen' : MODAL_PRESENTATION;
106
+ const onRequestClose = useCallback(
107
+ () => makeRequestClose(backRef, close, blockDismiss)(),
108
+ [close, blockDismiss],
109
+ );
110
+
111
+ return (
112
+ <>
113
+ <MyazaButton
114
+ label={label ?? defaultTriggerLabel(config)}
115
+ disabled={disabled}
116
+ fullWidth={false}
117
+ onPress={openFlow}
118
+ />
119
+ <Modal
120
+ visible={open}
121
+ animationType={MODAL_ANIMATION}
122
+ presentationStyle={presentation}
123
+ statusBarTranslucent
124
+ navigationBarTranslucent
125
+ onRequestClose={onRequestClose}
126
+ onDismiss={blockDismiss ? undefined : close}
127
+ >
128
+ {open ? <KycFlow onClose={close} backRef={backRef} /> : null}
129
+ </Modal>
130
+ </>
131
+ );
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // useMyazaKYC — for fully custom triggers. Returns the controller plus a bound
136
+ // `MyazaKYCModal` host element to render once in your tree. The consumer's own
137
+ // trigger element is plain (no theme dependency); the modal carries its own
138
+ // provider, mounted fresh on each open.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface UseMyazaKYCReturn {
142
+ open: () => void;
143
+ close: () => void;
144
+ isOpen: boolean;
145
+ currentStep: KYCStep | null;
146
+ /** Render this once in your component tree (RN has no implicit portal). */
147
+ MyazaKYCModal: React.FC;
148
+ }
149
+
150
+ export function useMyazaKYC<C extends SupportedCountry = SupportedCountry>(
151
+ config: MyazaKYCConfig<C>,
152
+ ): UseMyazaKYCReturn {
153
+ const [isOpen, setIsOpen] = useState(false);
154
+ const [currentStep, setCurrentStep] = useState<KYCStep | null>(null);
155
+ const closedRef = useRef(true);
156
+
157
+ // Consumers pass an inline config object, so its identity changes on every
158
+ // render of their component. Keep the LATEST config in a ref and read it from
159
+ // the (stable) callbacks below. This is what keeps `wrappedConfig` and the
160
+ // `MyazaKYCModal` element stable across re-renders — otherwise a step change
161
+ // (which re-renders the consumer) would rebuild the modal subtree, and on iOS
162
+ // a remounted presented <Modal> fires onDismiss → an unwanted close().
163
+ const configRef = useRef(config);
164
+ configRef.current = config;
165
+ const backRef = useRef<(() => BackResult) | null>(null);
166
+
167
+ const open = useCallback(() => {
168
+ closedRef.current = false;
169
+ setIsOpen(true);
170
+ }, []);
171
+ const close = useCallback(() => {
172
+ if (closedRef.current) return;
173
+ closedRef.current = true;
174
+ setIsOpen(false);
175
+ setCurrentStep(null);
176
+ configRef.current.onClose?.();
177
+ }, []);
178
+
179
+ // Stable wrapped config: spreads the latest config but overrides onStepChange
180
+ // to surface the live step to the consumer. Built once (the spread reads the
181
+ // ref at call time via the wrapped callbacks), so the modal's provider/store
182
+ // isn't recreated on each render.
183
+ const wrappedConfig = useMemo<MyazaKYCConfig<C>>(
184
+ () => ({
185
+ ...configRef.current,
186
+ onStart: () => configRef.current.onStart?.(),
187
+ onStepChange: (step: KYCStep) => {
188
+ setCurrentStep(step);
189
+ configRef.current.onStepChange?.(step);
190
+ },
191
+ onSubmit: (submission) => configRef.current.onSubmit?.(submission),
192
+ onError: (error) => configRef.current.onError?.(error),
193
+ onClose: () => configRef.current.onClose?.(),
194
+ }),
195
+ [],
196
+ );
197
+
198
+ // disableClose blocks user-initiated dismissal — only the returned close()
199
+ // can dismiss. Present full-screen (no iOS swipe-down). The Android hardware
200
+ // back goes through onRequestClose, which walks the flow back a step (or
201
+ // closes from the first step, unless disableClose) — same as the trigger
202
+ // component. Read once from the ref so the modal element stays stable; the
203
+ // flag isn't expected to flip mid-session.
204
+ const blockDismiss = configRef.current.disableClose === true;
205
+ const onRequestClose = useCallback(
206
+ () => makeRequestClose(backRef, close, blockDismiss)(),
207
+ [close, blockDismiss],
208
+ );
209
+
210
+ // Stable modal element — depends only on open/close state, never on the
211
+ // changing consumer config (see configRef above).
212
+ const MyazaKYCModal = useCallback<React.FC>(
213
+ () => (
214
+ <Modal
215
+ visible={isOpen}
216
+ animationType={MODAL_ANIMATION}
217
+ presentationStyle={blockDismiss ? 'fullScreen' : MODAL_PRESENTATION}
218
+ statusBarTranslucent
219
+ navigationBarTranslucent
220
+ onRequestClose={onRequestClose}
221
+ onDismiss={blockDismiss ? undefined : close}
222
+ >
223
+ {/* Fresh provider/store per open. */}
224
+ {isOpen ? (
225
+ <KycRuntimeProvider config={wrappedConfig}>
226
+ <KycFlow onClose={close} backRef={backRef} />
227
+ </KycRuntimeProvider>
228
+ ) : null}
229
+ </Modal>
230
+ ),
231
+ [wrappedConfig, isOpen, close, blockDismiss, onRequestClose],
232
+ );
233
+
234
+ return { open, close, isOpen, currentStep, MyazaKYCModal };
235
+ }
@@ -0,0 +1,39 @@
1
+ import { cardCropRect } from '../services/cardCrop';
2
+ import { CARD_GUIDE_ASPECT, PASSPORT_GUIDE_ASPECT } from '../config/idTypes';
3
+
4
+ describe('cardCropRect', () => {
5
+ it('produces a crop whose aspect equals the guide aspect', () => {
6
+ const r = cardCropRect(4000, 3000, CARD_GUIDE_ASPECT);
7
+ expect(r.width / r.height).toBeCloseTo(CARD_GUIDE_ASPECT, 3);
8
+ });
9
+
10
+ it('stays within the image bounds', () => {
11
+ for (const [w, h] of [
12
+ [4000, 3000], // landscape sensor
13
+ [3000, 4000], // portrait sensor
14
+ [1920, 1080], // 16:9
15
+ ] as const) {
16
+ const r = cardCropRect(w, h, CARD_GUIDE_ASPECT);
17
+ expect(r.originX).toBeGreaterThanOrEqual(0);
18
+ expect(r.originY).toBeGreaterThanOrEqual(0);
19
+ expect(r.originX + r.width).toBeLessThanOrEqual(w + 0.01);
20
+ expect(r.originY + r.height).toBeLessThanOrEqual(h + 0.01);
21
+ }
22
+ });
23
+
24
+ it('centres the crop within the cover-fit 3:4 slice (landscape sensor)', () => {
25
+ // 4000×3000 → visible slice is 2250×3000 centred at x=875.
26
+ const r = cardCropRect(4000, 3000, CARD_GUIDE_ASPECT);
27
+ const sliceCentreX = 875 + 2250 / 2;
28
+ expect(r.originX + r.width / 2).toBeCloseTo(sliceCentreX, 3);
29
+ expect(r.originY + r.height / 2).toBeCloseTo(3000 / 2, 3); // vertically centred (no shift)
30
+ });
31
+
32
+ it('gives passports a taller crop (smaller aspect) than ID cards', () => {
33
+ const card = cardCropRect(4000, 3000, CARD_GUIDE_ASPECT);
34
+ const passport = cardCropRect(4000, 3000, PASSPORT_GUIDE_ASPECT);
35
+ expect(passport.height).toBeGreaterThan(card.height);
36
+ expect(passport.width).toBeCloseTo(card.width, 3); // same 88% width
37
+ expect(passport.width / passport.height).toBeCloseTo(PASSPORT_GUIDE_ASPECT, 3);
38
+ });
39
+ });
@@ -0,0 +1,34 @@
1
+ import { collectDeviceMetadata, inferDeviceType } from '../services/deviceMetadata';
2
+
3
+ // expo-device's DeviceType enum: UNKNOWN=0, PHONE=1, TABLET=2, DESKTOP=3, TV=4.
4
+ describe('inferDeviceType', () => {
5
+ it('maps the expo-device DeviceType enum to the dashboard classes', () => {
6
+ expect(inferDeviceType({ deviceType: 1 })).toBe('mobile'); // PHONE
7
+ expect(inferDeviceType({ deviceType: 2 })).toBe('tablet'); // TABLET
8
+ expect(inferDeviceType({ deviceType: 3 })).toBe('desktop'); // DESKTOP
9
+ expect(inferDeviceType({ deviceType: 4 })).toBe('unknown'); // TV
10
+ expect(inferDeviceType({ deviceType: 0 })).toBe('unknown'); // UNKNOWN
11
+ });
12
+
13
+ it('falls back to the platform when expo-device is unavailable (Node → ios → mobile)', () => {
14
+ expect(inferDeviceType(undefined)).toBe('mobile');
15
+ expect(inferDeviceType({ deviceType: null })).toBe('mobile');
16
+ });
17
+ });
18
+
19
+ describe('collectDeviceMetadata', () => {
20
+ it('always sets a device.type (never the empty dashboard "—")', () => {
21
+ const meta = collectDeviceMetadata();
22
+ expect(meta.device.type).toBeDefined();
23
+ expect(['mobile', 'tablet', 'desktop', 'unknown']).toContain(meta.device.type);
24
+ });
25
+
26
+ it('keeps sdkType react-native and includes the device sub-object', () => {
27
+ const meta = collectDeviceMetadata();
28
+ expect(meta.sdkType).toBe('react-native');
29
+ // Under the Node test runner expo-device isn't resolvable, so the type comes
30
+ // from the platform fallback (ios → mobile) and brand/vendor are omitted.
31
+ expect(meta.device.type).toBe('mobile');
32
+ expect(meta.device).toHaveProperty('type');
33
+ });
34
+ });