@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +277 -7
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +146 -10
  9. package/ios/HybridMyazaFaceDetector.swift +8 -3
  10. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  12. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  14. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  16. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  18. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  19. package/package.json +30 -9
  20. package/src/MyazaKYC.tsx +4 -11
  21. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  22. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  23. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  24. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  25. package/src/assets/fonts/OFL-Karla.txt +93 -0
  26. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  27. package/src/assets/fonts/README.md +12 -0
  28. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  30. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  31. package/src/capture/useAutoCapture.ts +8 -1
  32. package/src/components/CountryField.tsx +8 -0
  33. package/src/components/CountryRegionPicker.tsx +184 -0
  34. package/src/components/DialCodePicker.tsx +47 -68
  35. package/src/components/DialCodeRow.tsx +102 -0
  36. package/src/components/DocumentCropper.tsx +30 -24
  37. package/src/components/DocumentReview.tsx +11 -1
  38. package/src/components/DocumentReviewSide.tsx +6 -2
  39. package/src/components/DocumentReviewZoom.tsx +5 -1
  40. package/src/components/FramedMapPicker.tsx +122 -0
  41. package/src/components/GeoBadge.tsx +34 -0
  42. package/src/components/Icon.tsx +10 -192
  43. package/src/components/KycFlow.tsx +69 -34
  44. package/src/components/LineSkeleton.tsx +100 -0
  45. package/src/components/MapChrome.tsx +67 -0
  46. package/src/components/MapPinMarker.tsx +54 -0
  47. package/src/components/MapPinPicker.tsx +179 -0
  48. package/src/components/MilestoneTrack.tsx +155 -0
  49. package/src/components/MyazaButton.tsx +9 -0
  50. package/src/components/MyazaInput.tsx +17 -1
  51. package/src/components/OptionRow.tsx +22 -3
  52. package/src/components/PhoneNumberInput.tsx +8 -0
  53. package/src/components/PoweredBy.tsx +5 -5
  54. package/src/components/PresenceBlocks.tsx +153 -0
  55. package/src/components/RequiredDocumentPill.tsx +114 -0
  56. package/src/components/StepHeader.tsx +23 -3
  57. package/src/components/StepView.tsx +17 -0
  58. package/src/components/StickyActions.tsx +56 -0
  59. package/src/components/dialCodeRows.ts +64 -0
  60. package/src/components/documentReviewCopy.ts +39 -0
  61. package/src/components/fonts.ts +14 -17
  62. package/src/components/icon-map.ts +176 -0
  63. package/src/components/stepHeaderMeta.tsx +48 -5
  64. package/src/config/addressCollection.ts +125 -0
  65. package/src/config/biometricOptions.ts +100 -0
  66. package/src/config/business.ts +20 -1
  67. package/src/config/businessSteps.ts +8 -2
  68. package/src/config/consentStep.ts +19 -0
  69. package/src/config/documentCaptureMethods.ts +31 -0
  70. package/src/config/proofOfAddress.ts +54 -11
  71. package/src/config/regions.ts +24 -0
  72. package/src/config/stepOrder.ts +79 -5
  73. package/src/config/uploadLimits.ts +35 -0
  74. package/src/config/workflowMerge.ts +13 -5
  75. package/src/index.ts +49 -1
  76. package/src/lib/address-current-location.ts +171 -0
  77. package/src/lib/address-field-modes.ts +119 -0
  78. package/src/lib/address-flow.ts +172 -0
  79. package/src/lib/address-helpers.ts +48 -0
  80. package/src/lib/address-line.ts +76 -0
  81. package/src/lib/address-pin-move.ts +97 -0
  82. package/src/lib/address-step-recovery.ts +63 -0
  83. package/src/lib/authed-image.ts +78 -0
  84. package/src/lib/biometric-copy.ts +51 -0
  85. package/src/lib/captureRing.ts +83 -0
  86. package/src/lib/country-adoption.ts +89 -0
  87. package/src/lib/documentCaptureCheck.ts +136 -0
  88. package/src/lib/inferred-country.ts +65 -0
  89. package/src/lib/livenessLayout.ts +49 -0
  90. package/src/lib/map-frame.ts +159 -0
  91. package/src/lib/map-tiles.ts +143 -0
  92. package/src/lib/model-ready.ts +82 -0
  93. package/src/lib/poa-country-gate.ts +27 -0
  94. package/src/lib/prime-models.ts +50 -0
  95. package/src/lib/resubmit.ts +36 -7
  96. package/src/lib/result-copy.ts +116 -0
  97. package/src/lib/result-wait.ts +53 -0
  98. package/src/lib/review-map-surface.ts +26 -0
  99. package/src/lib/scope.ts +31 -0
  100. package/src/lib/selfie-sharpness.ts +211 -0
  101. package/src/lib/selfie-upload-wait.ts +79 -0
  102. package/src/lib/street-view-fov.ts +42 -0
  103. package/src/lib/webview-available.ts +47 -0
  104. package/src/liveness/avatarSource.ts +59 -0
  105. package/src/liveness/useLiveness.ts +6 -1
  106. package/src/liveness/useModelReady.ts +9 -55
  107. package/src/mrz/extract.ts +71 -4
  108. package/src/mrz/textRecognizer.ts +36 -0
  109. package/src/mrz/useTextModelReady.ts +21 -0
  110. package/src/presence/background-math.ts +100 -0
  111. package/src/presence/background-store.ts +82 -0
  112. package/src/presence/background.ts +165 -0
  113. package/src/presence/foreground-service.ts +193 -0
  114. package/src/presence/fs.ts +51 -0
  115. package/src/presence/geofence.ts +41 -0
  116. package/src/presence/math.ts +44 -0
  117. package/src/presence/post.ts +40 -0
  118. package/src/presence/report.ts +87 -0
  119. package/src/presence/sampler.ts +110 -0
  120. package/src/presence/status.ts +92 -0
  121. package/src/presence/store.ts +94 -0
  122. package/src/presence/tier.ts +36 -0
  123. package/src/presence/watch-wait.ts +81 -0
  124. package/src/screens/AddressCountryControl.tsx +115 -0
  125. package/src/screens/BusinessDetailsFields.tsx +3 -0
  126. package/src/screens/BusinessDetailsStep.tsx +2 -0
  127. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  128. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  129. package/src/screens/CompanyInfoFields.tsx +6 -1
  130. package/src/screens/ConsentStep.tsx +6 -17
  131. package/src/screens/ContactActions.tsx +54 -0
  132. package/src/screens/ContactDestinationField.tsx +4 -0
  133. package/src/screens/ContactEntryPanel.tsx +75 -0
  134. package/src/screens/ContactVerificationStep.tsx +26 -56
  135. package/src/screens/CountrySelectStep.tsx +13 -147
  136. package/src/screens/DocumentCaptureStep.tsx +177 -93
  137. package/src/screens/LivenessAvatar.tsx +33 -16
  138. package/src/screens/LivenessStep.tsx +188 -49
  139. package/src/screens/MrzScanView.tsx +33 -0
  140. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  141. package/src/screens/ProofOfAddressParts.tsx +151 -0
  142. package/src/screens/ProofOfAddressStep.tsx +58 -99
  143. package/src/screens/SubmittedBadge.tsx +25 -0
  144. package/src/screens/SubmittedError.tsx +64 -0
  145. package/src/screens/SubmittedResult.tsx +117 -0
  146. package/src/screens/SubmittedStep.tsx +71 -164
  147. package/src/screens/SubmittedSuccess.tsx +127 -0
  148. package/src/screens/SubmittedWaiting.tsx +45 -0
  149. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  150. package/src/screens/address/AddressIntroGate.tsx +148 -0
  151. package/src/screens/address/AddressMapStub.tsx +50 -0
  152. package/src/screens/address/AddressPinStep.tsx +178 -0
  153. package/src/screens/address/AddressReviewStep.tsx +162 -0
  154. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  155. package/src/screens/address/AddressSearchField.tsx +115 -0
  156. package/src/screens/address/AddressSearchStep.tsx +94 -0
  157. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  158. package/src/screens/address/DetailsSheet.tsx +175 -0
  159. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  160. package/src/screens/address/EntranceDropzone.tsx +178 -0
  161. package/src/screens/address/EntranceFraming.tsx +66 -0
  162. package/src/screens/address/EntrancePills.tsx +60 -0
  163. package/src/screens/address/FramedStreetView.tsx +155 -0
  164. package/src/screens/address/IntroDisclosures.tsx +210 -0
  165. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  166. package/src/screens/address/PinSummaryRow.tsx +105 -0
  167. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  168. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  169. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  170. package/src/screens/address/SearchResults.tsx +105 -0
  171. package/src/screens/address/SearchScreen.tsx +176 -0
  172. package/src/screens/address/SkipForNow.tsx +43 -0
  173. package/src/screens/address/StreetViewChrome.tsx +81 -0
  174. package/src/screens/address/detail-values.ts +22 -0
  175. package/src/screens/address/fix-source.ts +27 -0
  176. package/src/screens/address/index.ts +8 -0
  177. package/src/screens/address/meta.ts +44 -0
  178. package/src/screens/address/use-address-flow.ts +200 -0
  179. package/src/screens/address/use-label-pin.ts +80 -0
  180. package/src/screens/address/use-pin-actions.ts +192 -0
  181. package/src/screens/consent/model.ts +79 -10
  182. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  183. package/src/screens/document/UploadPhase.tsx +177 -0
  184. package/src/screens/document/useDocumentCamera.ts +109 -0
  185. package/src/screens/liveness/CaptureRing.tsx +91 -0
  186. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  187. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  188. package/src/screens/liveness/LivenessOutcome.tsx +8 -4
  189. package/src/screens/liveness/SelfiePreview.tsx +31 -7
  190. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  191. package/src/screens/liveness/index.ts +4 -0
  192. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  193. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  194. package/src/screens/useAddressPhotoAttach.ts +77 -0
  195. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  196. package/src/screens/usePoaAttach.ts +12 -15
  197. package/src/services/api-types.ts +119 -1
  198. package/src/services/api-verify-types.ts +37 -0
  199. package/src/services/api.ts +149 -3
  200. package/src/services/deviceMetadata.ts +1 -1
  201. package/src/services/location.ts +234 -0
  202. package/src/services/mediaCompress.ts +23 -5
  203. package/src/services/uploadErrors.ts +30 -0
  204. package/src/services/workflowGate.ts +7 -0
  205. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  206. package/src/store/address.ts +97 -0
  207. package/src/store/derive.ts +29 -4
  208. package/src/store/kycStore.ts +120 -5
  209. package/src/store/serverConfig.ts +18 -0
  210. package/src/store/session.ts +23 -4
  211. package/src/store/state.ts +134 -0
  212. package/src/store/submit.ts +18 -1
  213. package/src/types/config.ts +63 -1
  214. package/src/types/verification.ts +12 -0
  215. package/src/types/workflow.ts +97 -1
  216. package/src/assets/liveness/Blink.gif +0 -0
  217. package/src/assets/liveness/Nod.gif +0 -0
  218. package/src/assets/liveness/Smile.gif +0 -0
  219. package/src/assets/liveness/Turn.gif +0 -0
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
 
@@ -67,13 +70,18 @@ default on Expo SDK 56):
67
70
  "expo": {
68
71
  "newArchEnabled": true,
69
72
  "plugins": [
70
- ["react-native-vision-camera", { "enableMicrophonePermission": false }],
71
73
  "@myazahq/kyc-sdk-react-native"
72
74
  ]
73
75
  }
74
76
  }
75
77
  ```
76
78
 
79
+ VisionCamera v5 ships **no config plugin** (v4 did), so it takes no `plugins`
80
+ entry. Listing it makes `expo prebuild` load the package's main entry as a
81
+ plugin and fail with `Cannot find module '.../lib/VisionCamera'`. The camera
82
+ permission and usage strings come from this SDK's own plugin; pass
83
+ `cameraPermission` to change the iOS wording.
84
+
77
85
  Then build a dev client (regenerates the native projects):
78
86
 
79
87
  ```sh
@@ -82,8 +90,64 @@ npx expo run:ios # iOS
82
90
  JAVA_HOME=/path/to/jdk-17 npx expo run:android # Android — needs JDK 17
83
91
  ```
84
92
 
85
- > The SDK plugin accepts an optional custom camera prompt:
86
- > `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…" }]`.
93
+ > The SDK plugin accepts optional custom prompts:
94
+ > `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…",
95
+ > "locationPermission": "Your message…", "nfcPermission": "Your message…" }]`.
96
+
97
+ #### NFC is opt-in
98
+
99
+ Reading the eMRTD chip in a passport or chip ID card needs platform permissions
100
+ that most apps should not carry, so the SDK declares **none of them** unless you
101
+ ask:
102
+
103
+ ```jsonc
104
+ ["@myazahq/kyc-sdk-react-native", { "nfc": true }]
105
+ ```
106
+
107
+ With it on, the plugin writes the Android `NFC` permission plus a
108
+ `uses-feature android:required="false"` declaration, and on iOS the
109
+ `NFCReaderUsageDescription`, the reader-session entitlement and the eMRTD
110
+ application identifier.
111
+
112
+ Leave it off and none of that is added. The chip step already checks for a radio
113
+ at runtime and skips itself when there is none, so an app built without NFC
114
+ behaves exactly like a phone that has no NFC hardware — every other step is
115
+ unaffected.
116
+
117
+ Two reasons this is opt-in rather than on by default:
118
+
119
+ - **iOS code signing.** The reader entitlement requires the App ID to carry the
120
+ "NFC Tag Reading" capability in the Apple Developer portal. Adding it
121
+ unconditionally breaks signing for every consumer who does not read chips and
122
+ has not enabled that capability.
123
+ - **Play Store visibility.** `android.permission.NFC` makes Android infer that
124
+ the app *requires* an NFC radio, which hides it from every device without one.
125
+ That is why the permission and the `uses-feature` declaration are written
126
+ together and never separately.
127
+
128
+ > **Changed in 3.0.0.** Earlier versions declared the Android NFC permission in
129
+ > the library manifest, so it merged into every host app regardless of this
130
+ > option — the opt-in only ever governed iOS. If you read chips on Android and
131
+ > have not set `nfc: true`, set it now: without it the permission is no longer
132
+ > declared and the chip step will find no radio. Apps that never read chips need
133
+ > no change and lose a permission they never wanted.
134
+
135
+ ### Optional modules
136
+
137
+ Four `expo-*` modules are **optional peers**. The SDK loads each one lazily and
138
+ carries on without it, so nothing crashes if you skip them — but each one is
139
+ missing a capability rather than a detail, so install them deliberately:
140
+
141
+ | Module | What installing it buys | Without it |
142
+ |--------|------------------------|-----------|
143
+ | `expo-device` | Make, model, manufacturer and physical-vs-simulator in the device metadata | Those fields are omitted and the device class is guessed from the platform, so Device Intelligence has a weaker fingerprint and shared-device detection suffers |
144
+ | `expo-application` | Your app's id, version and build number on the submission | The `app` block is omitted entirely, so a result cannot be traced to the build that produced it |
145
+ | `expo-localization` | The device's region, explicitly | Country defaults and the reported locale fall back to the JS runtime's locale, which often carries no region at all (`en` rather than `en-NG`) |
146
+ | `expo-document-picker` | "Choose a file" on the proof-of-address and KYB document steps | Those steps accept a camera capture only, so a PDF bank statement cannot be submitted at all |
147
+
148
+ ```sh
149
+ npx expo install expo-device expo-application expo-localization expo-document-picker
150
+ ```
87
151
 
88
152
  ### Bare React Native app (no Expo prebuild)
89
153
 
@@ -100,7 +164,8 @@ npm install @myazahq/kyc-sdk-react-native \
100
164
  react-native-worklets react-native-nitro-modules react-native-nitro-image \
101
165
  react-native-safe-area-context react-native-svg \
102
166
  expo expo-image-manipulator expo-image-picker expo-speech expo-font \
103
- expo-glass-effect expo-application expo-crypto expo-device expo-localization
167
+ expo-glass-effect expo-crypto \
168
+ expo-location
104
169
 
105
170
  # 3. iOS pods:
106
171
  cd ios && pod install && cd ..
@@ -113,11 +178,15 @@ prebuild):
113
178
  ```xml
114
179
  <key>NSCameraUsageDescription</key>
115
180
  <string>We use the camera to photograph your ID and capture a live selfie.</string>
181
+ <key>NSLocationWhenInUseUsageDescription</key>
182
+ <string>We use your location to confirm you are at the address you pin during verification.</string>
116
183
  ```
117
184
  - **Android** — add to `android/app/src/main/AndroidManifest.xml`:
118
185
  ```xml
119
186
  <uses-permission android:name="android.permission.CAMERA" />
120
187
  <uses-permission android:name="android.permission.INTERNET" />
188
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
189
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
121
190
  ```
122
191
  - Ensure the **New Architecture** is enabled (`newArchEnabled=true` in
123
192
  `android/gradle.properties`; `RCT_NEW_ARCH_ENABLED=1` for the iOS Podfile install),
@@ -235,6 +304,7 @@ export default function VerifyScreen() {
235
304
  | `enableSelfie` | `boolean` | `true` | Capture a selfie during liveness. |
236
305
  | `enableDocumentCapture` | `boolean` | `true` | Enable the document-scan step for document IDs. |
237
306
  | `allowDocumentUpload` | `boolean` | `true` | Allow picking a document photo from the gallery as an alternative to the camera. `false` hides every "upload instead" affordance (it's still offered on the camera-permission-denied screen as an escape hatch). |
307
+ | `allowDocumentScan` | `boolean` | `true` | Allow photographing the document with the live camera. `false` makes document capture upload-only: the SDK never asks for camera access and the applicant chooses a photo of each side from their device. At least one of `allowDocumentScan` and `allowDocumentUpload` must stay on; a config that switches both off keeps the camera on. |
238
308
  | `enableLiveness` | `boolean` | `true` | Run the liveness challenge step. The server can still disable it per ID type. |
239
309
  | `livenessMode` | `'gestures' \| 'flash' \| 'both'` | `'gestures'` | How liveness is proven. See [Liveness](#liveness). |
240
310
  | `flashSequenceLength` | `number` (2–5) | `4` | Colours in the flash sequence, for `'flash'` / `'both'`. |
@@ -527,7 +597,9 @@ terminal `4xx` surface immediately. The UI shows a top toast while retrying, and
527
597
  If the user denies camera access, the SDK shows a clear "camera access needed"
528
598
  screen (with an **Open Settings** action) instead of hanging, and reports
529
599
  `camera_permission_denied` to `onError`. Document capture additionally offers a
530
- gallery-upload fallback unless `allowDocumentUpload` is `false`.
600
+ gallery-upload fallback unless `allowDocumentUpload` is `false`. When
601
+ `allowDocumentScan` is `false` the document step never requests the camera, so
602
+ this screen cannot appear there: each side is a photo chosen from the device.
531
603
 
532
604
  ### Liveness quality guards
533
605
 
@@ -553,6 +625,67 @@ v5 Nitro frame processor: the camera frame never crosses the JS bridge. The self
553
625
  is **auto-captured** once challenges pass (anti-spoofing — never user-triggered),
554
626
  and a short liveness video is recorded and uploaded best-effort.
555
627
 
628
+ ## App size
629
+
630
+ The SDK adds native machine learning to a host app, and that is where the weight
631
+ sits. Two things decide what a user actually downloads, and the defaults are
632
+ already the small ones — but the third and fourth are the host app's to set, and
633
+ they are worth more than everything the SDK can do on its own.
634
+
635
+ **On-device models are fetched, not bundled.** Face detection and text
636
+ recognition both run on Google ML Kit on Android, and the SDK depends on the
637
+ Play Services variants, which download their models on first use. Measured on a
638
+ real integrator's release APK, the bundled pair cost **18.5 MB per device**
639
+ (arm64: text 10.55 MB, face 7.95 MB) plus `.tflite` files in `assets/`, which
640
+ ship to every device because assets are not split by ABI. Fetched, that is about
641
+ 0.4 MB of shims.
642
+
643
+ The SDK primes both downloads the moment the flow opens, so they overlap the
644
+ consent and ID-type screens. If a model has not arrived by the time it is needed,
645
+ the step says so rather than failing silently — the liveness step waits and
646
+ explains, and the MRZ scanner tells the user the code cannot be read and lets
647
+ them continue without the chip.
648
+
649
+ The trade is real: the Play Services variants need Google Play Services, so they
650
+ do not work on Huawei or bare AOSP builds. If you ship to those devices, put this
651
+ in your root `build.gradle` and you get fully-offline models back, at 18.5 MB per
652
+ device:
653
+
654
+ ```gradle
655
+ ext { myazaKycBundledMlKit = true }
656
+ ```
657
+
658
+ **Ship an App Bundle, or filter your ABIs.** Native libraries dominate the rest
659
+ of the download, and a universal APK carries every architecture at once. An `.aab`
660
+ lets Play deliver only the one a device needs. If you must ship an APK, name the
661
+ architectures your users actually have:
662
+
663
+ ```gradle
664
+ android {
665
+ defaultConfig {
666
+ ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
667
+ }
668
+ }
669
+ ```
670
+
671
+ **Turn on R8 and resource shrinking.** The SDK ships its own consumer rules
672
+ (`consumer-rules.pro`), so you do not need to learn which of its classes Nitro
673
+ constructs by name:
674
+
675
+ ```gradle
676
+ android {
677
+ buildTypes {
678
+ release {
679
+ minifyEnabled true
680
+ shrinkResources true
681
+ }
682
+ }
683
+ }
684
+ ```
685
+
686
+ **Use Expo SDK 54 or newer.** Its default template builds smaller than earlier
687
+ ones, and the SDK's peer range assumes it.
688
+
556
689
  ## Documentation
557
690
 
558
691
  Full documentation, configuration options, and webhook setup: **[trust.myaza.co/documentation/sdks](https://trust.myaza.co/documentation/sdks)**.
@@ -560,3 +693,140 @@ Full documentation, configuration options, and webhook setup: **[trust.myaza.co/
560
693
  ## License
561
694
 
562
695
  MIT © Flitstack Technologies Inc.
696
+
697
+ ## Presence reporting (Address Intelligence)
698
+
699
+ When a workflow enables presence verification (`addressCollection.presence.enabled`),
700
+ the SDK stores the confirmed pin on-device at capture. Call the reporter from your
701
+ app on a natural moment (app open works well):
702
+
703
+ ```tsx
704
+ import { reportAddressPresence, clearPresencePin } from '@myazahq/kyc-sdk-react-native';
705
+
706
+ const result = await reportAddressPresence({
707
+ apiKey: 'pk_live_…',
708
+ externalUserId: 'user_42', // the same userId the KYC flow ran with
709
+ });
710
+ // result.reason: 'reported' | 'no_pin' | 'services_off' | 'no_fix' | 'outside_fence' | 'network_error'
711
+ ```
712
+
713
+ It never throws and never blocks startup. The geofence is evaluated ON-DEVICE:
714
+ only the derived record (calendar day + a night flag) is transmitted, never a
715
+ coordinate. A fix outside the fence sends nothing (the server scores presence,
716
+ never absence); a mock-location fix is reported flagged. `clearPresencePin`
717
+ drops the stored pin (sign-out, or once the watch resolves).
718
+
719
+ ### Background monitoring (OS geofencing)
720
+
721
+ The stronger tier: the OS wakes the SDK on fence crossings around the stored
722
+ pin, app closed or not, so dwell and nights accrue with nobody in the loop.
723
+ Entries stamp a timestamp; exits fold the dwell span into per-day aggregates
724
+ and flush them. As with the foreground tier, only the derived day records
725
+ ever leave the phone.
726
+
727
+ Three opt-ins, each deliberate:
728
+
729
+ 1. Install the optional peer: `npx expo install expo-task-manager` (without
730
+ it the background tier simply does not exist — never a crash).
731
+ 2. Declare background location via the config plugin — this is what changes
732
+ your app's store review posture, so it is never a default:
733
+
734
+ ```json
735
+ ["@myazahq/kyc-sdk-react-native", { "location": "always" }]
736
+ ```
737
+
738
+ 3. Register the task at your app's ROOT module (before the component tree —
739
+ a task defined inside a component never fires headlessly), then enable:
740
+
741
+ ```tsx
742
+ // index.js
743
+ import { registerBackgroundPresence } from '@myazahq/kyc-sdk-react-native';
744
+ registerBackgroundPresence();
745
+
746
+ // later, after the KYC flow stored a pin:
747
+ const result = await enableBackgroundPresence({
748
+ apiKey: 'pk_live_…',
749
+ externalUserId: 'user_42',
750
+ });
751
+ // result.reason: 'enabled' | 'module_missing' | 'no_pin'
752
+ // | 'foreground_denied' | 'background_denied' | 'start_failed'
753
+ ```
754
+
755
+ `disableBackgroundPresence()` disarms the fence. A permission refusal leaves
756
+ the foreground tier working exactly as before — the tiers degrade, never
757
+ break.
758
+
759
+ ### The Android foreground service (reliability on OEM-managed phones)
760
+
761
+ A geofence alone is not reliable on Android once a manufacturer's battery
762
+ manager decides your app is idle: transitions are dropped, nothing says so,
763
+ and the watch quietly lapses to inconclusive. The phones on that list (Tecno,
764
+ Infinix, itel, Xiaomi, Oppo, Vivo) are the ones the market carries. A
765
+ foreground service, with its persistent notification, is the one thing those
766
+ managers leave alone — and OkHi's own integration guidance for the same
767
+ markets is exactly this.
768
+
769
+ Opt-in, Android only (iOS region monitoring is reliable on its own), on the
770
+ same `location: "always"` plugin setting, which also declares the
771
+ `FOREGROUND_SERVICE` and `FOREGROUND_SERVICE_LOCATION` permissions it needs.
772
+ `registerBackgroundPresence()` at the root already defines its task; then:
773
+
774
+ ```tsx
775
+ const result = await enableForegroundService({
776
+ apiKey: 'pk_live_…',
777
+ externalUserId: 'user_42',
778
+ notification: {
779
+ title: 'Address verification in progress', // shown in the status bar
780
+ body: 'Open the app to see your progress',
781
+ color: '#5645F5',
782
+ },
783
+ });
784
+ // result.reason: 'enabled' | 'unsupported_platform' | 'module_missing' | 'no_pin'
785
+ // | 'foreground_denied' | 'background_denied' | 'start_failed'
786
+ ```
787
+
788
+ While it runs, a low-power fix every ten minutes (or hundred metres) is
789
+ turned into the same enter/exit spans the geofence folds, on the same stored
790
+ state, so the two never double-count a stay; the queue flushes while the
791
+ process is alive; and a fence the OS dropped (a location toggle clears every
792
+ registered fence) is re-armed. `disableForegroundService()` stops it and its
793
+ notification. Word the notification honestly — it is on screen for days.
794
+
795
+ ### Which tier is running?
796
+
797
+ Permissions get revoked in Settings and nothing tells the app. Ask:
798
+
799
+ ```tsx
800
+ import { presenceStatus, openLocationSettings } from '@myazahq/kyc-sdk-react-native';
801
+
802
+ const status = await presenceStatus('user_42');
803
+ // status.tier: 'background' | 'foreground' | 'none'
804
+ // plus pinStored, alwaysOn, locationServicesEnabled, both permission states,
805
+ // geofenceArmed, foregroundServiceRunning
806
+ if (!status.locationServicesEnabled) {
807
+ // The phone's location toggle is off: permission granted or not, no fix
808
+ // can be taken. Android deep-links to the toggle itself.
809
+ await openLocationSettings('services');
810
+ } else if (status.tier === 'none' && status.pinStored) {
811
+ // The road back runs through Settings — no OS allows re-prompting in-app.
812
+ await openLocationSettings();
813
+ }
814
+ ```
815
+
816
+ ### Showing the person where the check stands
817
+
818
+ Somebody kept from a feature until their address is verified should be able
819
+ to see the progress in your app, without a webhook relayed through your
820
+ backend. The status endpoint is publishable-safe and enumeration-safe (an
821
+ unknown user and a user with no watch answer the same `not_started` shape):
822
+
823
+ ```ts
824
+ const res = await fetch(`${serverUrl}/api/kyc/address/presence/${externalUserId}`, {
825
+ headers: { Authorization: `Bearer pk_live_…` },
826
+ });
827
+ const { status, progress, tier } = await res.json();
828
+ // status: 'not_started' | 'in_progress' | 'verified' | 'failed' | 'inconclusive' | 'expired' | 'revoked'
829
+ // progress.score: 0..1 on WEIGHTED evidence (five foreground nights and three
830
+ // geofence nights both read 1); nightsObserved/daysObserved beside it
831
+ // tier: 'background' | 'foreground' | null — what is actually feeding it
832
+ ```
@@ -32,6 +32,11 @@ def safeExtGet(prop, fallback) {
32
32
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
33
33
  }
34
34
 
35
+ // Resolved ONCE and used twice: it picks the ML Kit artifacts below AND is
36
+ // compiled into BuildConfig so the native readiness checks can answer without
37
+ // asking Play Services about a model that is sitting in the APK.
38
+ def bundledMlKit = safeExtGet('myazaKycBundledMlKit', false).toString().toBoolean()
39
+
35
40
  android {
36
41
  namespace 'co.myazahq.kyc.rn'
37
42
  compileSdk safeExtGet('compileSdkVersion', 34)
@@ -41,6 +46,17 @@ android {
41
46
  minSdk safeExtGet('minSdkVersion', 24)
42
47
  targetSdk safeExtGet('targetSdkVersion', 34)
43
48
 
49
+ // Shipped to the host app's shrinker so an integrator can enable R8 without
50
+ // learning which of our classes Nitro constructs by name. See the file.
51
+ consumerProguardFiles 'consumer-rules.pro'
52
+
53
+ // Whether the ML Kit models ship inside the APK. Read by the face detector
54
+ // and the text recogniser to short-circuit their readiness checks: on a
55
+ // bundled build the model is already here, and asking Play Services whether
56
+ // it holds the OPTIONAL module would answer "no" and strand a build that
57
+ // works perfectly offline.
58
+ buildConfigField "boolean", "BUNDLED_ML_KIT", "${bundledMlKit}"
59
+
44
60
  externalNativeBuild {
45
61
  cmake {
46
62
  cppFlags "-frtti -fexceptions -Wall -Wextra -fstack-protector-all"
@@ -125,24 +141,22 @@ dependencies {
125
141
  // GMS. An org shipping to those devices sets `myazaKycBundledMlKit = true` in
126
142
  // its root build.gradle ext and gets today's fully-offline behaviour back.
127
143
  //
128
- // Availability is NOT inferred per frame — `isModelReady()` is checked once
129
- // before the camera opens (see HybridMyazaFaceDetector.kt), because a missing
130
- // model would otherwise be indistinguishable from "no face in frame".
131
- if (safeExtGet('myazaKycBundledMlKit', false).toString().toBoolean()) {
144
+ // Availability is NOT inferred per frame. Both models carry the same
145
+ // `isModelReady()` / `prepareModel()` contract, checked once before the
146
+ // camera opens, because the failure they guard against is identical: an
147
+ // absent model is indistinguishable from an empty frame (no face found, no
148
+ // text found), so a fresh install would sit on "position your face" or never
149
+ // auto-capture, with nothing able to say why.
150
+ //
151
+ // The Kotlin and Swift sources are unchanged by the switch — the unbundled
152
+ // artifacts expose the same `com.google.mlkit.vision.*` API, and the
153
+ // OptionalModuleApi the readiness check needs comes from the shared
154
+ // play-services common artifact both variants depend on.
155
+ if (bundledMlKit) {
132
156
  implementation 'com.google.mlkit:face-detection:16.1.6'
157
+ implementation 'com.google.mlkit:text-recognition:16.0.1'
133
158
  } else {
134
159
  implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.1.0'
160
+ implementation 'com.google.android.gms:play-services-mlkit-text-recognition:19.0.1'
135
161
  }
136
-
137
- // Text recognition is STILL BUNDLED, deliberately, and it is the larger of the
138
- // two (10.55 MB vs 7.95 MB on arm64) — so this is the bigger half of the win,
139
- // deferred rather than abandoned.
140
- //
141
- // Unbundling it needs the same isModelReady()/prepareModel() contract the face
142
- // detector just gained. Without that, a missing OCR model is indistinguishable
143
- // from "no text in frame": document auto-capture would simply never fire and
144
- // MRZ scanning would never produce a key for the chip read, with nothing shown
145
- // to the user. Shipping the dependency swap ahead of the availability handling
146
- // would trade 10 MB for a silent failure on every fresh install.
147
- implementation 'com.google.mlkit:text-recognition:16.0.1'
148
162
  }
@@ -0,0 +1,22 @@
1
+ # Consumer R8 / ProGuard rules for @myazahq/kyc-sdk-react-native.
2
+ #
3
+ # Merged into the host app's shrinker configuration automatically through
4
+ # `consumerProguardFiles`, so an integrator who enables R8 (`minifyEnabled true`,
5
+ # `shrinkResources true`) needs nothing on their side. Enabling R8 is the single
6
+ # biggest thing an integrator can do for the Java half of the SDK's footprint:
7
+ # the unminified dex the SDK pulls in (CameraX, Play Services shims, Expo, Kotlin
8
+ # reflection) is mostly code no flow ever calls.
9
+ #
10
+ # Nitro constructs every hybrid implementation BY CLASS NAME from C++: the
11
+ # generated OnLoad registers one JNI constructor per spec, which the shrinker
12
+ # cannot see. A shrunk build without these rules removes exactly the classes the
13
+ # registry then fails to find, and the failure is a crash at the first camera
14
+ # frame rather than a build error.
15
+ -keep class com.margelo.nitro.** { *; }
16
+ -keep class co.myazahq.kyc.rn.** { *; }
17
+
18
+ # JJ2000, the JPEG 2000 decoder for the chip portrait, predates the shrinker
19
+ # era. Keeping it whole costs about 0.3 MB and takes a whole class of
20
+ # shrink-time surprises out of a decode that runs once per chip read.
21
+ -keep class jj2000.** { *; }
22
+ -dontwarn jj2000.**
@@ -1,12 +1,23 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <!--
3
- NFC, for reading a passport's eMRTD chip. `required="false"` on the feature
4
- is deliberate: the rest of the SDK works without NFC, and marking it
5
- required would hide the host app from every device that has no NFC radio.
6
- The chip step checks availability at runtime and skips instead.
3
+ NFC is DELIBERATELY NOT DECLARED HERE.
4
+ ======================================
5
+ Reading a passport's eMRTD chip needs android.permission.NFC, but a library
6
+ manifest is MERGED into every host app, so declaring it here gave the
7
+ permission to every integrator — including the majority whose workflows
8
+ never touch a chip, and who had no way to remove it. That is the opposite
9
+ of opt-in, and it silently contradicted the config plugin's own `nfc`
10
+ option, which defaults to OFF.
11
+
12
+ The permission AND the feature are now written by app.plugin.js, and only
13
+ on `nfc: true`. They must travel TOGETHER — see ensureNfcFeatureOptional()
14
+ there for why the feature is not an optional extra: Android infers
15
+ `required="true"` from the permission alone, which hides the host app from
16
+ every device with no NFC radio.
17
+
18
+ The chip step still checks availability at runtime and auto-skips on a
19
+ device with no radio, so a host that opts out loses nothing else.
7
20
  -->
8
- <uses-permission android:name="android.permission.NFC" />
9
- <uses-feature android:name="android.hardware.nfc" android:required="false" />
10
21
 
11
22
  <application>
12
23
  <!--
@@ -9,9 +9,6 @@ import com.google.mlkit.vision.face.Face
9
9
  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
- import com.google.android.gms.common.moduleinstall.ModuleInstall
13
- import com.google.android.gms.common.moduleinstall.ModuleInstallRequest
14
- import com.margelo.nitro.NitroModules
15
12
  import com.margelo.nitro.core.Promise
16
13
  import java.util.concurrent.CountDownLatch
17
14
  import java.util.concurrent.TimeUnit
@@ -45,64 +42,13 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
45
42
 
46
43
  // ── Model availability ───────────────────────────────────────────────────
47
44
  //
48
- // The default build fetches ML Kit's models through Play Services instead of
49
- // bundling them (android/build.gradle), which is what keeps ~18.5 MB per
50
- // device out of the APK. The cost is a window where detection cannot run:
51
- // first launch before the download completes, or a device with no GMS at all.
52
- //
53
- // `detectFace` CANNOT report that. Its only channel is FaceResult, where a
54
- // missing model and an empty frame are both `faceCount: 0` — so a user on a
55
- // fresh install would watch "position your face" forever with nothing to
56
- // explain it. Hence a separate, explicit contract, checked before the camera
57
- // opens rather than inferred per frame.
58
- //
59
- // Cached rather than queried live: `areModulesAvailable` is asynchronous and
60
- // `isModelReady` is a synchronous Nitro call, so blocking the worklet thread
61
- // to answer it would be worse than the problem. The flag starts false and is
62
- // set by prepareModel(), which the SDK primes at flow start.
63
- @Volatile private var modelReady = false
45
+ // Shared with the text recogniser: the rule and the reasoning are identical,
46
+ // so they live once in MlKitModelReadiness.kt rather than twice here.
47
+ private val readiness = MlKitModelReadiness(detector)
64
48
 
65
- override fun isModelReady(): Boolean = modelReady
49
+ override fun isModelReady(): Boolean = readiness.isReady()
66
50
 
67
- override fun prepareModel(): Promise<Boolean> = Promise.async {
68
- if (modelReady) return@async true
69
-
70
- val ctx = NitroModules.applicationContext
71
- ?: return@async false // No context — cannot ask Play Services anything.
72
-
73
- val latch = CountDownLatch(1)
74
- var ok = false
75
- try {
76
- val client = ModuleInstall.getClient(ctx)
77
- // areModulesAvailable() answers "is it already here"; deferredInstall()
78
- // asks Play Services to fetch it in the background if not. Requesting the
79
- // install unconditionally is simpler AND correct — it is a no-op when the
80
- // module is already present, and starting it early is the entire point.
81
- client.areModulesAvailable(detector)
82
- .addOnSuccessListener { response ->
83
- ok = response.areModulesAvailable()
84
- if (!ok) {
85
- client.deferredInstall(
86
- ModuleInstallRequest.newBuilder().addApi(detector).build(),
87
- )
88
- }
89
- latch.countDown()
90
- }
91
- .addOnFailureListener {
92
- // Thrown on devices without Google Play Services at all (Huawei, bare
93
- // AOSP). Not an error to retry — it will never succeed on this device.
94
- // Such orgs should build with `myazaKycBundledMlKit = true`.
95
- ok = false
96
- latch.countDown()
97
- }
98
- latch.await(MODEL_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
99
- } catch (_: Throwable) {
100
- ok = false
101
- }
102
-
103
- modelReady = ok
104
- ok
105
- }
51
+ override fun prepareModel(): Promise<Boolean> = Promise.async { readiness.prepare() }
106
52
 
107
53
  @ExperimentalGetImage
108
54
  override fun detectFace(frame: HybridFrameSpec): FaceResult {
@@ -119,13 +65,34 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
119
65
  val rotation = proxy.imageInfo.rotationDegrees
120
66
  val input = InputImage.fromMediaImage(mediaImage, rotation)
121
67
 
68
+ // Read the frame's dimensions HERE, synchronously, and let the callback use
69
+ // these ints rather than the Image.
70
+ //
71
+ // The listener runs on the main Looper whenever ML Kit finishes, which is
72
+ // not necessarily before `latch.await` below gives up. Once detectFace has
73
+ // returned, VisionCamera recycles the frame and closes the underlying
74
+ // android.media.Image — so a late callback touching `mediaImage.width`
75
+ // threw `IllegalStateException: Image is already closed` and took the whole
76
+ // app down, every time a face was actually FOUND (the failure listener
77
+ // reads nothing, so only success crashed).
78
+ //
79
+ // Unbundling the models is what exposed it: play-services-mlkit dispatches
80
+ // through Play Services IPC and is slower than the in-process bundled
81
+ // artifact, so the callback overruns the 200 ms wait routinely on real
82
+ // mid-range hardware. Seen on a TECNO KM5 (Android 15).
83
+ //
84
+ // The text recogniser already reads its dimensions up front for the same
85
+ // reason; this makes the two agree.
86
+ val imageWidth = mediaImage.width
87
+ val imageHeight = mediaImage.height
88
+
122
89
  var result: FaceResult? = null
123
90
  val latch = CountDownLatch(1)
124
91
  detector.process(input)
125
92
  .addOnSuccessListener { faces ->
126
93
  val face = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }
127
94
  result = face?.let {
128
- toResult(it, mediaImage.width, mediaImage.height, rotation, faces.size, brightness, faceRgb)
95
+ toResult(it, imageWidth, imageHeight, rotation, faces.size, brightness, faceRgb)
129
96
  }
130
97
  latch.countDown()
131
98
  }
@@ -283,14 +250,4 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
283
250
  faceG = -1.0,
284
251
  faceB = -1.0,
285
252
  )
286
-
287
- private companion object {
288
- /**
289
- * Bound on the availability query. It is a local Play Services call, not the
290
- * model download — the download runs in the background afterwards. Generous
291
- * enough for a cold Play Services process, short enough that a wedged one
292
- * cannot stall flow start.
293
- */
294
- const val MODEL_CHECK_TIMEOUT_MS = 3_000L
295
- }
296
253
  }
@@ -32,6 +32,20 @@ class HybridMyazaTextRecognizer : HybridMyazaTextRecognizerSpec() {
32
32
  private val recognizer: TextRecognizer =
33
33
  TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
34
34
 
35
+ // ── Model availability ───────────────────────────────────────────────────
36
+ //
37
+ // Shared with the face detector, because the failure is the same one: the
38
+ // model is fetched through Play Services rather than bundled, and a missing
39
+ // one is indistinguishable from a frame with no text in it. Left to the
40
+ // per-frame result, document auto-capture would simply never fire and the MRZ
41
+ // would never produce a key for the chip read, with nothing shown to explain
42
+ // it. See MlKitModelReadiness.kt.
43
+ private val readiness = MlKitModelReadiness(recognizer)
44
+
45
+ override fun isModelReady(): Boolean = readiness.isReady()
46
+
47
+ override fun prepareModel(): Promise<Boolean> = Promise.async { readiness.prepare() }
48
+
35
49
  @ExperimentalGetImage
36
50
  override fun recognizeText(frame: HybridFrameSpec, bottomFraction: Double): TextResult {
37
51
  val proxy: ImageProxy = (frame as? NativeFrame)?.image ?: return TextResult(emptyArray())