@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +11 -5
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +48 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
package/README.md
CHANGED
|
@@ -56,8 +56,11 @@ npx expo install @myazahq/kyc-sdk-react-native \
|
|
|
56
56
|
react-native-safe-area-context react-native-svg
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Add the config plugins to `app.json`. The SDK's plugin adds the iOS camera
|
|
60
|
-
|
|
59
|
+
Add the config plugins to `app.json`. The SDK's plugin adds the iOS camera and
|
|
60
|
+
location usage strings + Android `CAMERA`/`INTERNET` and foreground location
|
|
61
|
+
permissions (location backs the address-collection step's "Use my current
|
|
62
|
+
location" and presence attestation; `["@myazahq/kyc-sdk-react-native",
|
|
63
|
+
{ "location": false }]` opts out); the VisionCamera plugin wires up
|
|
61
64
|
the camera + frame processors. Make sure the **New Architecture** is on (it is by
|
|
62
65
|
default on Expo SDK 56):
|
|
63
66
|
|
|
@@ -82,8 +85,9 @@ npx expo run:ios # iOS
|
|
|
82
85
|
JAVA_HOME=/path/to/jdk-17 npx expo run:android # Android — needs JDK 17
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
> The SDK plugin accepts
|
|
86
|
-
> `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…"
|
|
88
|
+
> The SDK plugin accepts optional custom prompts:
|
|
89
|
+
> `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…",
|
|
90
|
+
> "locationPermission": "Your message…" }]`.
|
|
87
91
|
|
|
88
92
|
### Bare React Native app (no Expo prebuild)
|
|
89
93
|
|
|
@@ -100,7 +104,8 @@ npm install @myazahq/kyc-sdk-react-native \
|
|
|
100
104
|
react-native-worklets react-native-nitro-modules react-native-nitro-image \
|
|
101
105
|
react-native-safe-area-context react-native-svg \
|
|
102
106
|
expo expo-image-manipulator expo-image-picker expo-speech expo-font \
|
|
103
|
-
expo-glass-effect expo-application expo-crypto expo-device expo-localization
|
|
107
|
+
expo-glass-effect expo-application expo-crypto expo-device expo-localization \
|
|
108
|
+
expo-location
|
|
104
109
|
|
|
105
110
|
# 3. iOS pods:
|
|
106
111
|
cd ios && pod install && cd ..
|
|
@@ -113,11 +118,15 @@ prebuild):
|
|
|
113
118
|
```xml
|
|
114
119
|
<key>NSCameraUsageDescription</key>
|
|
115
120
|
<string>We use the camera to photograph your ID and capture a live selfie.</string>
|
|
121
|
+
<key>NSLocationWhenInUseUsageDescription</key>
|
|
122
|
+
<string>We use your location to confirm you are at the address you pin during verification.</string>
|
|
116
123
|
```
|
|
117
124
|
- **Android** — add to `android/app/src/main/AndroidManifest.xml`:
|
|
118
125
|
```xml
|
|
119
126
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
120
127
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
128
|
+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
129
|
+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
121
130
|
```
|
|
122
131
|
- Ensure the **New Architecture** is enabled (`newArchEnabled=true` in
|
|
123
132
|
`android/gradle.properties`; `RCT_NEW_ARCH_ENABLED=1` for the iOS Podfile install),
|
|
@@ -560,3 +569,140 @@ Full documentation, configuration options, and webhook setup: **[trust.myaza.co/
|
|
|
560
569
|
## License
|
|
561
570
|
|
|
562
571
|
MIT © Flitstack Technologies Inc.
|
|
572
|
+
|
|
573
|
+
## Presence reporting (Address Intelligence)
|
|
574
|
+
|
|
575
|
+
When a workflow enables presence verification (`addressCollection.presence.enabled`),
|
|
576
|
+
the SDK stores the confirmed pin on-device at capture. Call the reporter from your
|
|
577
|
+
app on a natural moment (app open works well):
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
import { reportAddressPresence, clearPresencePin } from '@myazahq/kyc-sdk-react-native';
|
|
581
|
+
|
|
582
|
+
const result = await reportAddressPresence({
|
|
583
|
+
apiKey: 'pk_live_…',
|
|
584
|
+
externalUserId: 'user_42', // the same userId the KYC flow ran with
|
|
585
|
+
});
|
|
586
|
+
// result.reason: 'reported' | 'no_pin' | 'services_off' | 'no_fix' | 'outside_fence' | 'network_error'
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
It never throws and never blocks startup. The geofence is evaluated ON-DEVICE:
|
|
590
|
+
only the derived record (calendar day + a night flag) is transmitted, never a
|
|
591
|
+
coordinate. A fix outside the fence sends nothing (the server scores presence,
|
|
592
|
+
never absence); a mock-location fix is reported flagged. `clearPresencePin`
|
|
593
|
+
drops the stored pin (sign-out, or once the watch resolves).
|
|
594
|
+
|
|
595
|
+
### Background monitoring (OS geofencing)
|
|
596
|
+
|
|
597
|
+
The stronger tier: the OS wakes the SDK on fence crossings around the stored
|
|
598
|
+
pin, app closed or not, so dwell and nights accrue with nobody in the loop.
|
|
599
|
+
Entries stamp a timestamp; exits fold the dwell span into per-day aggregates
|
|
600
|
+
and flush them. As with the foreground tier, only the derived day records
|
|
601
|
+
ever leave the phone.
|
|
602
|
+
|
|
603
|
+
Three opt-ins, each deliberate:
|
|
604
|
+
|
|
605
|
+
1. Install the optional peer: `npx expo install expo-task-manager` (without
|
|
606
|
+
it the background tier simply does not exist — never a crash).
|
|
607
|
+
2. Declare background location via the config plugin — this is what changes
|
|
608
|
+
your app's store review posture, so it is never a default:
|
|
609
|
+
|
|
610
|
+
```json
|
|
611
|
+
["@myazahq/kyc-sdk-react-native", { "location": "always" }]
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
3. Register the task at your app's ROOT module (before the component tree —
|
|
615
|
+
a task defined inside a component never fires headlessly), then enable:
|
|
616
|
+
|
|
617
|
+
```tsx
|
|
618
|
+
// index.js
|
|
619
|
+
import { registerBackgroundPresence } from '@myazahq/kyc-sdk-react-native';
|
|
620
|
+
registerBackgroundPresence();
|
|
621
|
+
|
|
622
|
+
// later, after the KYC flow stored a pin:
|
|
623
|
+
const result = await enableBackgroundPresence({
|
|
624
|
+
apiKey: 'pk_live_…',
|
|
625
|
+
externalUserId: 'user_42',
|
|
626
|
+
});
|
|
627
|
+
// result.reason: 'enabled' | 'module_missing' | 'no_pin'
|
|
628
|
+
// | 'foreground_denied' | 'background_denied' | 'start_failed'
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
`disableBackgroundPresence()` disarms the fence. A permission refusal leaves
|
|
632
|
+
the foreground tier working exactly as before — the tiers degrade, never
|
|
633
|
+
break.
|
|
634
|
+
|
|
635
|
+
### The Android foreground service (reliability on OEM-managed phones)
|
|
636
|
+
|
|
637
|
+
A geofence alone is not reliable on Android once a manufacturer's battery
|
|
638
|
+
manager decides your app is idle: transitions are dropped, nothing says so,
|
|
639
|
+
and the watch quietly lapses to inconclusive. The phones on that list (Tecno,
|
|
640
|
+
Infinix, itel, Xiaomi, Oppo, Vivo) are the ones the market carries. A
|
|
641
|
+
foreground service, with its persistent notification, is the one thing those
|
|
642
|
+
managers leave alone — and OkHi's own integration guidance for the same
|
|
643
|
+
markets is exactly this.
|
|
644
|
+
|
|
645
|
+
Opt-in, Android only (iOS region monitoring is reliable on its own), on the
|
|
646
|
+
same `location: "always"` plugin setting, which also declares the
|
|
647
|
+
`FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_LOCATION` permissions it needs.
|
|
648
|
+
`registerBackgroundPresence()` at the root already defines its task; then:
|
|
649
|
+
|
|
650
|
+
```tsx
|
|
651
|
+
const result = await enableForegroundService({
|
|
652
|
+
apiKey: 'pk_live_…',
|
|
653
|
+
externalUserId: 'user_42',
|
|
654
|
+
notification: {
|
|
655
|
+
title: 'Address verification in progress', // shown in the status bar
|
|
656
|
+
body: 'Open the app to see your progress',
|
|
657
|
+
color: '#5645F5',
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
// result.reason: 'enabled' | 'unsupported_platform' | 'module_missing' | 'no_pin'
|
|
661
|
+
// | 'foreground_denied' | 'background_denied' | 'start_failed'
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
While it runs, a low-power fix every ten minutes (or hundred metres) is
|
|
665
|
+
turned into the same enter/exit spans the geofence folds, on the same stored
|
|
666
|
+
state, so the two never double-count a stay; the queue flushes while the
|
|
667
|
+
process is alive; and a fence the OS dropped (a location toggle clears every
|
|
668
|
+
registered fence) is re-armed. `disableForegroundService()` stops it and its
|
|
669
|
+
notification. Word the notification honestly — it is on screen for days.
|
|
670
|
+
|
|
671
|
+
### Which tier is running?
|
|
672
|
+
|
|
673
|
+
Permissions get revoked in Settings and nothing tells the app. Ask:
|
|
674
|
+
|
|
675
|
+
```tsx
|
|
676
|
+
import { presenceStatus, openLocationSettings } from '@myazahq/kyc-sdk-react-native';
|
|
677
|
+
|
|
678
|
+
const status = await presenceStatus('user_42');
|
|
679
|
+
// status.tier: 'background' | 'foreground' | 'none'
|
|
680
|
+
// plus pinStored, alwaysOn, locationServicesEnabled, both permission states,
|
|
681
|
+
// geofenceArmed, foregroundServiceRunning
|
|
682
|
+
if (!status.locationServicesEnabled) {
|
|
683
|
+
// The phone's location toggle is off: permission granted or not, no fix
|
|
684
|
+
// can be taken. Android deep-links to the toggle itself.
|
|
685
|
+
await openLocationSettings('services');
|
|
686
|
+
} else if (status.tier === 'none' && status.pinStored) {
|
|
687
|
+
// The road back runs through Settings — no OS allows re-prompting in-app.
|
|
688
|
+
await openLocationSettings();
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Showing the person where the check stands
|
|
693
|
+
|
|
694
|
+
Somebody kept from a feature until their address is verified should be able
|
|
695
|
+
to see the progress in your app, without a webhook relayed through your
|
|
696
|
+
backend. The status endpoint is publishable-safe and enumeration-safe (an
|
|
697
|
+
unknown user and a user with no watch answer the same `not_started` shape):
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
const res = await fetch(`${serverUrl}/api/kyc/address/presence/${externalUserId}`, {
|
|
701
|
+
headers: { Authorization: `Bearer pk_live_…` },
|
|
702
|
+
});
|
|
703
|
+
const { status, progress, tier } = await res.json();
|
|
704
|
+
// status: 'not_started' | 'in_progress' | 'verified' | 'failed' | 'inconclusive' | 'expired' | 'revoked'
|
|
705
|
+
// progress.score: 0..1 on WEIGHTED evidence (five foreground nights and three
|
|
706
|
+
// geofence nights both read 1); nightsObserved/daysObserved beside it
|
|
707
|
+
// tier: 'background' | 'foreground' | null — what is actually feeding it
|
|
708
|
+
```
|
|
@@ -10,7 +10,6 @@ import com.google.mlkit.vision.face.FaceDetection
|
|
|
10
10
|
import com.google.mlkit.vision.face.FaceDetector
|
|
11
11
|
import com.google.mlkit.vision.face.FaceDetectorOptions
|
|
12
12
|
import com.google.android.gms.common.moduleinstall.ModuleInstall
|
|
13
|
-
import com.google.android.gms.common.moduleinstall.ModuleInstallRequest
|
|
14
13
|
import com.margelo.nitro.NitroModules
|
|
15
14
|
import com.margelo.nitro.core.Promise
|
|
16
15
|
import java.util.concurrent.CountDownLatch
|
|
@@ -82,9 +81,12 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
|
|
|
82
81
|
.addOnSuccessListener { response ->
|
|
83
82
|
ok = response.areModulesAvailable()
|
|
84
83
|
if (!ok) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
// `deferredInstall` takes the OptionalModuleApi itself, varargs.
|
|
85
|
+
// A ModuleInstallRequest belongs to `installModules`, which is the
|
|
86
|
+
// FOREGROUND install with its own progress listener — a different
|
|
87
|
+
// call, and the one this used to hand its request to, which is why
|
|
88
|
+
// the Android build stopped compiling.
|
|
89
|
+
client.deferredInstall(detector)
|
|
88
90
|
}
|
|
89
91
|
latch.countDown()
|
|
90
92
|
}
|
package/app.plugin.js
CHANGED
|
@@ -37,6 +37,9 @@ const CAMERA_USAGE =
|
|
|
37
37
|
const NFC_USAGE =
|
|
38
38
|
'We read the secure chip in your passport or ID card to confirm the document is genuine.';
|
|
39
39
|
|
|
40
|
+
const LOCATION_USAGE =
|
|
41
|
+
'We use your location to confirm you are at the address you pin during verification.';
|
|
42
|
+
|
|
40
43
|
/**
|
|
41
44
|
* The eMRTD application identifier.
|
|
42
45
|
*
|
|
@@ -62,6 +65,23 @@ function withIosCameraUsage(config, customMessage) {
|
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* iOS location usage string, for the address-collection step's one-shot fix
|
|
70
|
+
* ("Use my current location" + the attest-presence claim). Written by DEFAULT
|
|
71
|
+
* because its ABSENCE is a crash: iOS kills an app that requests location
|
|
72
|
+
* without a usage string, and whether the step runs is decided by the org's
|
|
73
|
+
* WORKFLOW, not by anything the host app author can see at build time. An
|
|
74
|
+
* unused usage string costs nothing. `location: false` opts out.
|
|
75
|
+
*/
|
|
76
|
+
function withIosLocationUsage(config, customMessage) {
|
|
77
|
+
return withInfoPlist(config, (cfg) => {
|
|
78
|
+
if (!cfg.modResults.NSLocationWhenInUseUsageDescription) {
|
|
79
|
+
cfg.modResults.NSLocationWhenInUseUsageDescription = customMessage || LOCATION_USAGE;
|
|
80
|
+
}
|
|
81
|
+
return cfg;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
const AID_KEY = 'com.apple.developer.nfc.readersession.iso7816.select-identifiers';
|
|
66
86
|
const FORMATS_KEY = 'com.apple.developer.nfc.readersession.formats';
|
|
67
87
|
|
|
@@ -112,24 +132,84 @@ function withIosNfc(config, customMessage) {
|
|
|
112
132
|
});
|
|
113
133
|
}
|
|
114
134
|
|
|
115
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The Android permission list for a given set of opt-ins. Pure and exported
|
|
137
|
+
* so the tests can pin what `location: 'always'` declares — a permission
|
|
138
|
+
* that changes review posture must never arrive by accident.
|
|
139
|
+
*/
|
|
140
|
+
function androidPermissionsFor({ nfc, location, backgroundLocation }) {
|
|
141
|
+
return [
|
|
142
|
+
'android.permission.CAMERA',
|
|
143
|
+
'android.permission.INTERNET',
|
|
144
|
+
...(nfc ? ['android.permission.NFC'] : []),
|
|
145
|
+
// Foreground location, for the address-collection step's one-shot fix.
|
|
146
|
+
...(location
|
|
147
|
+
? ['android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION']
|
|
148
|
+
: []),
|
|
149
|
+
// The always-on presence tier (OkHi model, opt-in): OS geofencing needs
|
|
150
|
+
// the background grant, and Google Play reviews any app that declares
|
|
151
|
+
// it — which is why this is `location: 'always'` only, never a default.
|
|
152
|
+
// The foreground-service pair rides the same opt-in: FOREGROUND_SERVICE
|
|
153
|
+
// is a normal permission, and FOREGROUND_SERVICE_LOCATION (API 34+) is
|
|
154
|
+
// what lets expo-location's service declare the `location` type; Play
|
|
155
|
+
// reviews it under the same location declaration.
|
|
156
|
+
...(backgroundLocation
|
|
157
|
+
? [
|
|
158
|
+
'android.permission.ACCESS_BACKGROUND_LOCATION',
|
|
159
|
+
'android.permission.FOREGROUND_SERVICE',
|
|
160
|
+
'android.permission.FOREGROUND_SERVICE_LOCATION',
|
|
161
|
+
]
|
|
162
|
+
: []),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withAndroidPermissions(config, opts) {
|
|
116
167
|
return withAndroidManifest(config, (cfg) => {
|
|
117
|
-
AndroidConfig.Permissions.ensurePermissions(cfg.modResults,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
168
|
+
AndroidConfig.Permissions.ensurePermissions(cfg.modResults, androidPermissionsFor(opts));
|
|
169
|
+
return cfg;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** iOS side of the always-on tier: the Always usage string plus the location
|
|
174
|
+
* background mode, without which a geofence wake is refused. */
|
|
175
|
+
function withIosBackgroundLocation(config, customMessage) {
|
|
176
|
+
return withInfoPlist(config, (cfg) => {
|
|
177
|
+
if (!cfg.modResults.NSLocationAlwaysAndWhenInUseUsageDescription) {
|
|
178
|
+
cfg.modResults.NSLocationAlwaysAndWhenInUseUsageDescription =
|
|
179
|
+
customMessage || LOCATION_USAGE;
|
|
180
|
+
}
|
|
181
|
+
const modes = Array.isArray(cfg.modResults.UIBackgroundModes)
|
|
182
|
+
? cfg.modResults.UIBackgroundModes
|
|
183
|
+
: [];
|
|
184
|
+
if (!modes.includes('location')) modes.push('location');
|
|
185
|
+
cfg.modResults.UIBackgroundModes = modes;
|
|
122
186
|
return cfg;
|
|
123
187
|
});
|
|
124
188
|
}
|
|
125
189
|
|
|
126
190
|
/**
|
|
127
191
|
* @param {object} config
|
|
128
|
-
* @param {{ cameraPermission?: string, nfc?: boolean, nfcPermission?: string
|
|
192
|
+
* @param {{ cameraPermission?: string, nfc?: boolean, nfcPermission?: string,
|
|
193
|
+
* location?: boolean | 'always', locationPermission?: string }} [props]
|
|
129
194
|
*/
|
|
130
195
|
function withMyazaKyc(config, props = {}) {
|
|
196
|
+
const location = props.location !== false;
|
|
197
|
+
// `location: 'always'` opts into the background presence tier. Explicit on
|
|
198
|
+
// purpose: it changes the app's Play/App Store review posture, so it must
|
|
199
|
+
// never arrive as a side effect of a default.
|
|
200
|
+
const backgroundLocation = props.location === 'always';
|
|
131
201
|
config = withIosCameraUsage(config, props.cameraPermission);
|
|
132
|
-
config = withAndroidPermissions(config, {
|
|
202
|
+
config = withAndroidPermissions(config, {
|
|
203
|
+
nfc: props.nfc === true,
|
|
204
|
+
location,
|
|
205
|
+
backgroundLocation,
|
|
206
|
+
});
|
|
207
|
+
if (location) {
|
|
208
|
+
config = withIosLocationUsage(config, props.locationPermission);
|
|
209
|
+
}
|
|
210
|
+
if (backgroundLocation) {
|
|
211
|
+
config = withIosBackgroundLocation(config, props.locationPermission);
|
|
212
|
+
}
|
|
133
213
|
if (props.nfc === true) {
|
|
134
214
|
config = withIosNfc(config, props.nfcPermission);
|
|
135
215
|
}
|
|
@@ -141,4 +221,5 @@ module.exports = createRunOncePlugin(withMyazaKyc, pkg.name, pkg.version);
|
|
|
141
221
|
// consumers' app.json entries are unaffected.
|
|
142
222
|
module.exports.applyNfcInfoPlist = applyNfcInfoPlist;
|
|
143
223
|
module.exports.applyNfcEntitlements = applyNfcEntitlements;
|
|
224
|
+
module.exports.androidPermissionsFor = androidPermissionsFor;
|
|
144
225
|
module.exports.EMRTD_AID = EMRTD_AID;
|
|
@@ -303,8 +303,13 @@ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
|
|
|
303
303
|
return min(max(v, 0.0), 1.0)
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
/// Smile from outer-lip width relative to the face. Input band
|
|
307
|
-
///
|
|
306
|
+
/// Smile from outer-lip width relative to the face box. Input band
|
|
307
|
+
/// (0.36→0.46): the shared detector passes above 0.5, so the pass line sits
|
|
308
|
+
/// at a lip width of 0.41 of the box. It was 0.40→0.50 (pass at 0.45), which
|
|
309
|
+
/// on device needed a full grin — a modest, natural smile never crossed it.
|
|
310
|
+
/// ML Kit on Android returns a classifier probability instead, so this band
|
|
311
|
+
/// is the iOS half of one shared threshold: tune it here, not in
|
|
312
|
+
/// detectSmile, or Android loosens with it.
|
|
308
313
|
private static func smileProbability(_ landmarks: VNFaceLandmarks2D?) -> Double {
|
|
309
314
|
guard let pts = landmarks?.outerLips?.normalizedPoints, pts.count >= 4 else {
|
|
310
315
|
return 0.0
|
|
@@ -315,7 +320,7 @@ final class HybridMyazaFaceDetector: HybridMyazaFaceDetectorSpec {
|
|
|
315
320
|
minX = min(minX, Double(p.x)); maxX = max(maxX, Double(p.x))
|
|
316
321
|
}
|
|
317
322
|
let width = maxX - minX
|
|
318
|
-
let score = (width - 0.
|
|
323
|
+
let score = (width - 0.36) / (0.46 - 0.36)
|
|
319
324
|
return min(max(score, 0.0), 1.0)
|
|
320
325
|
}
|
|
321
326
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myazahq/kyc-sdk-react-native",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
|
|
5
5
|
"author": "Flitstack Technologies Inc.",
|
|
6
6
|
"repository": {
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"license": "MIT",
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"expo": ">=56",
|
|
52
|
+
"expo-task-manager": ">=14",
|
|
53
|
+
"react-native-webview": ">=13",
|
|
52
54
|
"react": ">=19",
|
|
53
55
|
"react-native": ">=0.83",
|
|
54
56
|
"react-native-nitro-image": ">=0.15",
|
|
@@ -58,6 +60,14 @@
|
|
|
58
60
|
"react-native-vision-camera-worklets": ">=5",
|
|
59
61
|
"react-native-worklets": ">=0.8"
|
|
60
62
|
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"expo-task-manager": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"react-native-webview": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
61
71
|
"dependencies": {
|
|
62
72
|
"@expo-google-fonts/karla": "^0.4.2",
|
|
63
73
|
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
|
@@ -74,11 +84,13 @@
|
|
|
74
84
|
"expo-image-manipulator": "~56.0.17",
|
|
75
85
|
"expo-image-picker": "~56.0.16",
|
|
76
86
|
"expo-localization": "~56.0.6",
|
|
87
|
+
"expo-location": "~56.0.24",
|
|
77
88
|
"expo-speech": "~56.0.3",
|
|
78
89
|
"lucide-react-native": "^1.17.0",
|
|
79
90
|
"react-native-compressor": "^1.19.0",
|
|
80
91
|
"react-native-vision-camera": "^5.0.11",
|
|
81
|
-
"zustand": "^5.0.2"
|
|
92
|
+
"zustand": "^5.0.2",
|
|
93
|
+
"expo-file-system": "~56.0.0"
|
|
82
94
|
},
|
|
83
95
|
"devDependencies": {
|
|
84
96
|
"@types/jest": "^29.5.12",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
|
+
import { Modal, Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { KycRuntimeProvider, MyazaThemeProvider } from './components/runtime';
|
|
5
|
+
import { MyazaButton } from './components/MyazaButton';
|
|
6
|
+
import { BiometricAuthFlow } from './screens/biometric/BiometricAuthFlow';
|
|
7
|
+
import { primeFaceModel } from './liveness/visionCameraFaceDetector';
|
|
8
|
+
import { defaultReauthLabel } from './lib/biometric-auth';
|
|
9
|
+
import type { KYCAppearance, ResolvedKYCConfig, SupportedCountry } from './types/config';
|
|
10
|
+
import type { KYCError } from './types/verification';
|
|
11
|
+
import type { BiometricAuthResponse } from './services/api-types-biometric';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// MyazaBiometricAuth — returning-user face re-authentication ("prove it's
|
|
15
|
+
// still you"). Mirrors the web SDK's MyazaBiometricAuth: a verified user
|
|
16
|
+
// re-authenticates with a live selfie matched 1:1 against their enrollment
|
|
17
|
+
// reference (no gov-DB call, no re-KYC). Self-contained: its own trigger +
|
|
18
|
+
// modal, hosting the SDK's real liveness step inside a scoped runtime.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface MyazaBiometricAuthProps {
|
|
22
|
+
/** Publishable API key (`pk_…`). The environment is derived from its prefix. */
|
|
23
|
+
apiKey: string;
|
|
24
|
+
/** Dev-only base-URL override for `pk_dev_` keys. */
|
|
25
|
+
devUrl?: string;
|
|
26
|
+
/** The org's user reference (Entity.externalUserId) to re-authenticate. */
|
|
27
|
+
externalUserId: string;
|
|
28
|
+
/** Presence Intelligence method (default 'gestures'). */
|
|
29
|
+
livenessMode?: 'gestures' | 'flash' | 'both';
|
|
30
|
+
flashSequenceLength?: number;
|
|
31
|
+
appearance?: KYCAppearance;
|
|
32
|
+
disableClose?: boolean;
|
|
33
|
+
/** Open the modal on mount (no trigger button is rendered). */
|
|
34
|
+
defaultOpen?: boolean;
|
|
35
|
+
/** Custom trigger label (default "Verify it's you"). */
|
|
36
|
+
children?: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
onOpen?: () => void;
|
|
39
|
+
/** The user re-authenticated. `token` is a single-use proof — verify it from
|
|
40
|
+
* your backend with a secret key at `/biometric/verify-proof`. */
|
|
41
|
+
onAuthenticated?: (result: { attemptId: string; confidence: number | null; token?: string }) => void;
|
|
42
|
+
/** The check ran but the user did not pass (no match / liveness failed). */
|
|
43
|
+
onFailed?: (result: { status: BiometricAuthResponse['status']; attemptId: string; confidence: number | null }) => void;
|
|
44
|
+
/** A technical error (network, not enrolled, insufficient credits, …). */
|
|
45
|
+
onError?: (error: KYCError) => void;
|
|
46
|
+
onClose?: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MODAL_PRESENTATION = Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen';
|
|
50
|
+
|
|
51
|
+
export function MyazaBiometricAuth(props: MyazaBiometricAuthProps): React.ReactElement {
|
|
52
|
+
const { children, disabled, defaultOpen, onOpen, onAuthenticated, onFailed, onError, onClose } = props;
|
|
53
|
+
const [open, setOpen] = useState(defaultOpen === true);
|
|
54
|
+
|
|
55
|
+
// The liveness step reads exactly the keys a KYC flow's config carries; the
|
|
56
|
+
// scope parks the step order on consent → liveness → submitted, and the
|
|
57
|
+
// flow only ever mounts the middle one. `country` is required by the
|
|
58
|
+
// resolved shape but nothing on this path reads it.
|
|
59
|
+
const config: ResolvedKYCConfig = {
|
|
60
|
+
apiKey: props.apiKey,
|
|
61
|
+
devUrl: props.devUrl,
|
|
62
|
+
country: 'NG' as SupportedCountry,
|
|
63
|
+
scope: 'biometric-authentication',
|
|
64
|
+
enableSelfie: true,
|
|
65
|
+
livenessMode: props.livenessMode ?? 'gestures',
|
|
66
|
+
flashSequenceLength: props.flashSequenceLength,
|
|
67
|
+
appearance: props.appearance,
|
|
68
|
+
disableClose: props.disableClose,
|
|
69
|
+
userId: props.externalUserId,
|
|
70
|
+
onError,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const openFlow = useCallback(() => {
|
|
74
|
+
void primeFaceModel();
|
|
75
|
+
setOpen(true);
|
|
76
|
+
onOpen?.();
|
|
77
|
+
}, [onOpen]);
|
|
78
|
+
const close = useCallback(() => {
|
|
79
|
+
setOpen(false);
|
|
80
|
+
onClose?.();
|
|
81
|
+
}, [onClose]);
|
|
82
|
+
const blockDismiss = props.disableClose === true;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<MyazaThemeProvider appearance={props.appearance}>
|
|
86
|
+
{defaultOpen !== true && (
|
|
87
|
+
<MyazaButton
|
|
88
|
+
label={children ?? defaultReauthLabel(props.appearance?.companyName)}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
fullWidth={false}
|
|
91
|
+
onPress={openFlow}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
<Modal
|
|
95
|
+
visible={open}
|
|
96
|
+
animationType="slide"
|
|
97
|
+
presentationStyle={blockDismiss ? 'fullScreen' : MODAL_PRESENTATION}
|
|
98
|
+
statusBarTranslucent
|
|
99
|
+
navigationBarTranslucent
|
|
100
|
+
onRequestClose={blockDismiss ? () => undefined : close}
|
|
101
|
+
onDismiss={blockDismiss ? undefined : close}
|
|
102
|
+
>
|
|
103
|
+
{open ? (
|
|
104
|
+
// A fresh store per open: the runtime provider creates it once per
|
|
105
|
+
// mount, and the modal's children mount once per open.
|
|
106
|
+
<KycRuntimeProvider config={config}>
|
|
107
|
+
<BiometricAuthFlow
|
|
108
|
+
externalUserId={props.externalUserId}
|
|
109
|
+
onAuthenticated={(r) => onAuthenticated?.({ attemptId: r.attemptId, confidence: r.confidence, token: r.token })}
|
|
110
|
+
onFailed={(r) => onFailed?.({ status: r.status, attemptId: r.attemptId, confidence: r.confidence })}
|
|
111
|
+
onError={onError}
|
|
112
|
+
onClose={close}
|
|
113
|
+
/>
|
|
114
|
+
</KycRuntimeProvider>
|
|
115
|
+
) : null}
|
|
116
|
+
</Modal>
|
|
117
|
+
</MyazaThemeProvider>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -21,6 +21,8 @@ export function CountryField({
|
|
|
21
21
|
value,
|
|
22
22
|
options,
|
|
23
23
|
onChange,
|
|
24
|
+
geoCountry,
|
|
25
|
+
grouped,
|
|
24
26
|
placeholder = 'Select a country',
|
|
25
27
|
searchPlaceholder = 'Search country',
|
|
26
28
|
}: {
|
|
@@ -29,6 +31,10 @@ export function CountryField({
|
|
|
29
31
|
/** The pickable countries — all ISO, or a workflow's registry subset. */
|
|
30
32
|
options: DialCodeOption[];
|
|
31
33
|
onChange: (code: string) => void;
|
|
34
|
+
/** The visitor's inferred country, pinned on top of the sheet as "Your location". */
|
|
35
|
+
geoCountry?: string | null;
|
|
36
|
+
/** Region headers between the rows, the country-select step's way. */
|
|
37
|
+
grouped?: boolean;
|
|
32
38
|
placeholder?: string;
|
|
33
39
|
searchPlaceholder?: string;
|
|
34
40
|
}): React.ReactElement {
|
|
@@ -75,6 +81,8 @@ export function CountryField({
|
|
|
75
81
|
visible={open}
|
|
76
82
|
options={options}
|
|
77
83
|
selected={value ?? ''}
|
|
84
|
+
pinned={geoCountry}
|
|
85
|
+
grouped={grouped}
|
|
78
86
|
searchPlaceholder={searchPlaceholder}
|
|
79
87
|
onPick={(code) => {
|
|
80
88
|
setOpen(false);
|