@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.
- package/KycSdkReactNative.podspec +32 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/android/CMakeLists.txt +29 -0
- package/android/build.gradle +110 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/cpp/cpp-adapter.cpp +12 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaFaceDetectorPackage.kt +36 -0
- package/android/src/main/java/co/myazahq/kyc/rn/MyazaStatusBarModule.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +134 -0
- package/app.plugin.js +55 -0
- package/expo-module.config.json +6 -0
- package/ios/HybridMyazaFaceDetector.swift +233 -0
- package/package.json +84 -0
- package/react-native.config.js +23 -0
- package/src/MyazaKYC.tsx +235 -0
- package/src/__tests__/cardCrop.test.ts +39 -0
- package/src/__tests__/deviceMetadata.test.ts +34 -0
- package/src/__tests__/errors.test.ts +34 -0
- package/src/__tests__/flow.test.ts +61 -0
- package/src/__tests__/gestureDetector.test.ts +37 -0
- package/src/__tests__/liveness.test.ts +112 -0
- package/src/__tests__/resolveUrl.test.ts +64 -0
- package/src/__tests__/validators.test.ts +38 -0
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/components/CameraPermissionView.tsx +116 -0
- package/src/components/CameraViewfinder.tsx +156 -0
- package/src/components/CountryFlag.tsx +52 -0
- package/src/components/DocumentCropper.tsx +325 -0
- package/src/components/GlassIconButton.tsx +92 -0
- package/src/components/Icon.tsx +125 -0
- package/src/components/KycFlow.tsx +205 -0
- package/src/components/KycSheet.tsx +224 -0
- package/src/components/MyazaAlert.tsx +57 -0
- package/src/components/MyazaButton.tsx +101 -0
- package/src/components/MyazaCard.tsx +48 -0
- package/src/components/MyazaInput.tsx +112 -0
- package/src/components/MyazaPulseLoader.tsx +71 -0
- package/src/components/StatusBarController.tsx +42 -0
- package/src/components/StepHeader.tsx +56 -0
- package/src/components/StepIndicator.tsx +74 -0
- package/src/components/Typography.tsx +69 -0
- package/src/components/fonts.ts +49 -0
- package/src/components/glass/GlassGroup.tsx +34 -0
- package/src/components/glass/GlassSurface.tsx +64 -0
- package/src/components/runtime.tsx +93 -0
- package/src/components/toast.tsx +154 -0
- package/src/components/useBranding.ts +27 -0
- package/src/components/useVideoRecorder.ts +122 -0
- package/src/config/captureSettings.ts +67 -0
- package/src/config/idTypes.ts +79 -0
- package/src/config/theme.ts +186 -0
- package/src/index.ts +54 -0
- package/src/liveness/challengeManager.ts +130 -0
- package/src/liveness/faceDetector.ts +79 -0
- package/src/liveness/gestureDetector.ts +80 -0
- package/src/liveness/speech.ts +66 -0
- package/src/liveness/types.ts +99 -0
- package/src/liveness/useLiveness.ts +484 -0
- package/src/liveness/visionCameraFaceDetector.ts +118 -0
- package/src/screens/ConsentStep.tsx +164 -0
- package/src/screens/DocumentCaptureStep.tsx +500 -0
- package/src/screens/IdInputStep.tsx +79 -0
- package/src/screens/IdTypeStep.tsx +142 -0
- package/src/screens/LivenessAvatar.tsx +69 -0
- package/src/screens/LivenessStep.tsx +615 -0
- package/src/screens/SubmittedStep.tsx +177 -0
- package/src/services/api.ts +291 -0
- package/src/services/cardCrop.ts +52 -0
- package/src/services/deviceMetadata.ts +185 -0
- package/src/services/errors.ts +92 -0
- package/src/services/mediaCompress.ts +129 -0
- package/src/services/resolveUrl.ts +98 -0
- package/src/services/retry.ts +70 -0
- package/src/services/validators.ts +103 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +44 -0
- package/src/store/kycStore.ts +288 -0
- package/src/store/serverConfig.ts +78 -0
- package/src/types/config.ts +239 -0
- package/src/types/country-flag-icons.d.ts +6 -0
- package/src/types/verification.ts +79 -0
- package/src/utils/platform.ts +23 -0
- package/src/utils/tokens.ts +10 -0
- package/src/utils/uuid.ts +31 -0
|
@@ -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
|
+
};
|
package/src/MyazaKYC.tsx
ADDED
|
@@ -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
|
+
});
|