@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.
Files changed (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. 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 usage
60
- string + Android `CAMERA`/`INTERNET` permissions; the VisionCamera plugin wires up
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 an optional custom camera prompt:
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
- client.deferredInstall(
86
- ModuleInstallRequest.newBuilder().addApi(detector).build(),
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
- function withAndroidPermissions(config, { nfc }) {
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
- 'android.permission.CAMERA',
119
- 'android.permission.INTERNET',
120
- ...(nfc ? ['android.permission.NFC'] : []),
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 }} [props]
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, { nfc: props.nfc === true });
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 (0.40→0.50):
307
- /// a neutral mouth sits near 0, a clear smile lands well above the threshold.
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.40) / (0.50 - 0.40)
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.5.0",
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);