@myazahq/kyc-sdk-react-native 2.6.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 (80) hide show
  1. package/README.md +128 -4
  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 -72
  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 +57 -2
  9. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  10. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  13. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  18. package/package.json +17 -8
  19. package/src/MyazaKYC.tsx +4 -11
  20. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  21. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  22. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  23. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  24. package/src/assets/fonts/OFL-Karla.txt +93 -0
  25. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  26. package/src/assets/fonts/README.md +12 -0
  27. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  28. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  30. package/src/capture/useAutoCapture.ts +8 -1
  31. package/src/components/DocumentCropper.tsx +30 -24
  32. package/src/components/DocumentReview.tsx +11 -1
  33. package/src/components/DocumentReviewSide.tsx +6 -2
  34. package/src/components/DocumentReviewZoom.tsx +5 -1
  35. package/src/components/Icon.tsx +8 -7
  36. package/src/components/KycFlow.tsx +9 -2
  37. package/src/components/MyazaButton.tsx +9 -0
  38. package/src/components/RequiredDocumentPill.tsx +114 -0
  39. package/src/components/StepHeader.tsx +23 -3
  40. package/src/components/documentReviewCopy.ts +39 -0
  41. package/src/components/fonts.ts +14 -17
  42. package/src/components/stepHeaderMeta.tsx +6 -1
  43. package/src/config/documentCaptureMethods.ts +31 -0
  44. package/src/config/workflowMerge.ts +2 -0
  45. package/src/index.ts +5 -10
  46. package/src/lib/documentCaptureCheck.ts +136 -0
  47. package/src/lib/model-ready.ts +82 -0
  48. package/src/lib/prime-models.ts +50 -0
  49. package/src/lib/resubmit.ts +36 -7
  50. package/src/lib/selfie-sharpness.ts +211 -0
  51. package/src/liveness/avatarSource.ts +59 -0
  52. package/src/liveness/useModelReady.ts +9 -55
  53. package/src/mrz/extract.ts +71 -4
  54. package/src/mrz/textRecognizer.ts +36 -0
  55. package/src/mrz/useTextModelReady.ts +21 -0
  56. package/src/screens/DocumentCaptureStep.tsx +177 -93
  57. package/src/screens/LivenessAvatar.tsx +18 -13
  58. package/src/screens/LivenessStep.tsx +37 -0
  59. package/src/screens/MrzScanView.tsx +33 -0
  60. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  61. package/src/screens/document/UploadPhase.tsx +177 -0
  62. package/src/screens/document/useDocumentCamera.ts +109 -0
  63. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  64. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  65. package/src/services/api-types.ts +28 -2
  66. package/src/services/api.ts +19 -3
  67. package/src/services/deviceMetadata.ts +1 -1
  68. package/src/services/mediaCompress.ts +23 -5
  69. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  70. package/src/store/kycStore.ts +8 -2
  71. package/src/types/config.ts +15 -0
  72. package/src/MyazaBiometricAuth.tsx +0 -119
  73. package/src/assets/liveness/Blink.gif +0 -0
  74. package/src/assets/liveness/Nod.gif +0 -0
  75. package/src/assets/liveness/Smile.gif +0 -0
  76. package/src/assets/liveness/Turn.gif +0 -0
  77. package/src/lib/biometric-auth.ts +0 -52
  78. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  79. package/src/services/api-biometric.ts +0 -37
  80. package/src/services/api-types-biometric.ts +0 -41
package/README.md CHANGED
@@ -70,13 +70,18 @@ default on Expo SDK 56):
70
70
  "expo": {
71
71
  "newArchEnabled": true,
72
72
  "plugins": [
73
- ["react-native-vision-camera", { "enableMicrophonePermission": false }],
74
73
  "@myazahq/kyc-sdk-react-native"
75
74
  ]
76
75
  }
77
76
  }
78
77
  ```
79
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
+
80
85
  Then build a dev client (regenerates the native projects):
81
86
 
82
87
  ```sh
@@ -87,7 +92,62 @@ JAVA_HOME=/path/to/jdk-17 npx expo run:android # Android — needs JDK 17
87
92
 
88
93
  > The SDK plugin accepts optional custom prompts:
89
94
  > `["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…",
90
- > "locationPermission": "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
+ ```
91
151
 
92
152
  ### Bare React Native app (no Expo prebuild)
93
153
 
@@ -104,7 +164,7 @@ npm install @myazahq/kyc-sdk-react-native \
104
164
  react-native-worklets react-native-nitro-modules react-native-nitro-image \
105
165
  react-native-safe-area-context react-native-svg \
106
166
  expo expo-image-manipulator expo-image-picker expo-speech expo-font \
107
- expo-glass-effect expo-application expo-crypto expo-device expo-localization \
167
+ expo-glass-effect expo-crypto \
108
168
  expo-location
109
169
 
110
170
  # 3. iOS pods:
@@ -244,6 +304,7 @@ export default function VerifyScreen() {
244
304
  | `enableSelfie` | `boolean` | `true` | Capture a selfie during liveness. |
245
305
  | `enableDocumentCapture` | `boolean` | `true` | Enable the document-scan step for document IDs. |
246
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. |
247
308
  | `enableLiveness` | `boolean` | `true` | Run the liveness challenge step. The server can still disable it per ID type. |
248
309
  | `livenessMode` | `'gestures' \| 'flash' \| 'both'` | `'gestures'` | How liveness is proven. See [Liveness](#liveness). |
249
310
  | `flashSequenceLength` | `number` (2–5) | `4` | Colours in the flash sequence, for `'flash'` / `'both'`. |
@@ -536,7 +597,9 @@ terminal `4xx` surface immediately. The UI shows a top toast while retrying, and
536
597
  If the user denies camera access, the SDK shows a clear "camera access needed"
537
598
  screen (with an **Open Settings** action) instead of hanging, and reports
538
599
  `camera_permission_denied` to `onError`. Document capture additionally offers a
539
- 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.
540
603
 
541
604
  ### Liveness quality guards
542
605
 
@@ -562,6 +625,67 @@ v5 Nitro frame processor: the camera frame never crosses the JS bridge. The self
562
625
  is **auto-captured** once challenges pass (anti-spoofing — never user-triggered),
563
626
  and a short liveness video is recorded and uploaded best-effort.
564
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
+
565
689
  ## Documentation
566
690
 
567
691
  Full documentation, configuration options, and webhook setup: **[trust.myaza.co/documentation/sdks](https://trust.myaza.co/documentation/sdks)**.
@@ -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,8 +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.margelo.nitro.NitroModules
14
12
  import com.margelo.nitro.core.Promise
15
13
  import java.util.concurrent.CountDownLatch
16
14
  import java.util.concurrent.TimeUnit
@@ -44,67 +42,13 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
44
42
 
45
43
  // ── Model availability ───────────────────────────────────────────────────
46
44
  //
47
- // The default build fetches ML Kit's models through Play Services instead of
48
- // bundling them (android/build.gradle), which is what keeps ~18.5 MB per
49
- // device out of the APK. The cost is a window where detection cannot run:
50
- // first launch before the download completes, or a device with no GMS at all.
51
- //
52
- // `detectFace` CANNOT report that. Its only channel is FaceResult, where a
53
- // missing model and an empty frame are both `faceCount: 0` — so a user on a
54
- // fresh install would watch "position your face" forever with nothing to
55
- // explain it. Hence a separate, explicit contract, checked before the camera
56
- // opens rather than inferred per frame.
57
- //
58
- // Cached rather than queried live: `areModulesAvailable` is asynchronous and
59
- // `isModelReady` is a synchronous Nitro call, so blocking the worklet thread
60
- // to answer it would be worse than the problem. The flag starts false and is
61
- // set by prepareModel(), which the SDK primes at flow start.
62
- @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)
63
48
 
64
- override fun isModelReady(): Boolean = modelReady
49
+ override fun isModelReady(): Boolean = readiness.isReady()
65
50
 
66
- override fun prepareModel(): Promise<Boolean> = Promise.async {
67
- if (modelReady) return@async true
68
-
69
- val ctx = NitroModules.applicationContext
70
- ?: return@async false // No context — cannot ask Play Services anything.
71
-
72
- val latch = CountDownLatch(1)
73
- var ok = false
74
- try {
75
- val client = ModuleInstall.getClient(ctx)
76
- // areModulesAvailable() answers "is it already here"; deferredInstall()
77
- // asks Play Services to fetch it in the background if not. Requesting the
78
- // install unconditionally is simpler AND correct — it is a no-op when the
79
- // module is already present, and starting it early is the entire point.
80
- client.areModulesAvailable(detector)
81
- .addOnSuccessListener { response ->
82
- ok = response.areModulesAvailable()
83
- if (!ok) {
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)
90
- }
91
- latch.countDown()
92
- }
93
- .addOnFailureListener {
94
- // Thrown on devices without Google Play Services at all (Huawei, bare
95
- // AOSP). Not an error to retry — it will never succeed on this device.
96
- // Such orgs should build with `myazaKycBundledMlKit = true`.
97
- ok = false
98
- latch.countDown()
99
- }
100
- latch.await(MODEL_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
101
- } catch (_: Throwable) {
102
- ok = false
103
- }
104
-
105
- modelReady = ok
106
- ok
107
- }
51
+ override fun prepareModel(): Promise<Boolean> = Promise.async { readiness.prepare() }
108
52
 
109
53
  @ExperimentalGetImage
110
54
  override fun detectFace(frame: HybridFrameSpec): FaceResult {
@@ -121,13 +65,34 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
121
65
  val rotation = proxy.imageInfo.rotationDegrees
122
66
  val input = InputImage.fromMediaImage(mediaImage, rotation)
123
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
+
124
89
  var result: FaceResult? = null
125
90
  val latch = CountDownLatch(1)
126
91
  detector.process(input)
127
92
  .addOnSuccessListener { faces ->
128
93
  val face = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }
129
94
  result = face?.let {
130
- toResult(it, mediaImage.width, mediaImage.height, rotation, faces.size, brightness, faceRgb)
95
+ toResult(it, imageWidth, imageHeight, rotation, faces.size, brightness, faceRgb)
131
96
  }
132
97
  latch.countDown()
133
98
  }
@@ -285,14 +250,4 @@ class HybridMyazaFaceDetector : HybridMyazaFaceDetectorSpec() {
285
250
  faceG = -1.0,
286
251
  faceB = -1.0,
287
252
  )
288
-
289
- private companion object {
290
- /**
291
- * Bound on the availability query. It is a local Play Services call, not the
292
- * model download — the download runs in the background afterwards. Generous
293
- * enough for a cold Play Services process, short enough that a wedged one
294
- * cannot stall flow start.
295
- */
296
- const val MODEL_CHECK_TIMEOUT_MS = 3_000L
297
- }
298
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())
@@ -0,0 +1,154 @@
1
+ package com.margelo.nitro.myazakyc
2
+
3
+ import co.myazahq.kyc.rn.BuildConfig
4
+ import com.google.android.gms.common.api.OptionalModuleApi
5
+ import com.google.android.gms.common.moduleinstall.InstallStatusListener
6
+ import com.google.android.gms.common.moduleinstall.ModuleInstall
7
+ import com.google.android.gms.common.moduleinstall.ModuleInstallClient
8
+ import com.google.android.gms.common.moduleinstall.ModuleInstallRequest
9
+ import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate
10
+ import com.margelo.nitro.NitroModules
11
+ import java.util.concurrent.CountDownLatch
12
+ import java.util.concurrent.TimeUnit
13
+
14
+ /**
15
+ * The readiness gate both ML Kit detectors share.
16
+ *
17
+ * The default build fetches ML Kit's models through Play Services instead of
18
+ * bundling them (android/build.gradle), which is what keeps ~18.5 MB per device
19
+ * out of the APK. The cost is a window where the detector cannot run: first
20
+ * launch before the download completes, or a device with no GMS at all.
21
+ *
22
+ * Neither detector can report that through its own result. A face detector
23
+ * answers in `FaceResult`, where a missing model and an empty frame are both
24
+ * `faceCount: 0`; a text recogniser answers in `TextResult`, where both are an
25
+ * empty `lines` array. So a user on a fresh install would watch "position your
26
+ * face" forever, or aim at a passport that never auto-captures, with nothing to
27
+ * explain it. Hence a separate, explicit contract, checked before the camera
28
+ * opens rather than inferred per frame.
29
+ *
30
+ * Two properties make it actually settle, and both were missing once:
31
+ * - The install is URGENT (`installModules`). `deferredInstall` leaves the
32
+ * timing to Play Services, which may wait for an idle, charging phone; right
33
+ * for a model somebody might want next week, wrong for one a person is
34
+ * waiting on now.
35
+ * - The answer is REFRESHED. `isModelReady` is a synchronous Nitro call and
36
+ * `areModulesAvailable` is asynchronous, so it returns the cached flag, but
37
+ * while the flag is false each call also starts a background re-check. The
38
+ * cache used to be set once at flow start and never again, so a download that
39
+ * finished a few seconds later was never noticed and the step gave up.
40
+ */
41
+ class MlKitModelReadiness(private val api: OptionalModuleApi) {
42
+ // A bundled build has the model in the APK, so there is nothing to wait for
43
+ // and nothing to ask. Asking anyway would be worse than useless: Play
44
+ // Services answers about its OWN optional modules, so it would report "not
45
+ // available" and strand a build that works perfectly offline — which is the
46
+ // exact scenario the bundled flag exists to serve.
47
+ @Volatile private var ready = BuildConfig.BUNDLED_ML_KIT
48
+ @Volatile private var checking = false
49
+ @Volatile private var installing = false
50
+
51
+ fun isReady(): Boolean {
52
+ if (!ready) refresh()
53
+ return ready
54
+ }
55
+
56
+ /**
57
+ * Ask Play Services for the model, returning true once it is usable, and
58
+ * request an urgent install when it is not.
59
+ *
60
+ * Safe to call repeatedly; returns immediately when already ready. Blocking
61
+ * — callers run it inside a Nitro `Promise.async`.
62
+ */
63
+ fun prepare(): Boolean {
64
+ if (ready) return true
65
+
66
+ val ctx = NitroModules.applicationContext
67
+ ?: return false // No context — cannot ask Play Services anything.
68
+
69
+ val latch = CountDownLatch(1)
70
+ try {
71
+ val client = ModuleInstall.getClient(ctx)
72
+ client.areModulesAvailable(api)
73
+ .addOnSuccessListener { response ->
74
+ if (response.areModulesAvailable()) ready = true else requestInstall(client)
75
+ latch.countDown()
76
+ }
77
+ .addOnFailureListener {
78
+ // Thrown on devices without Google Play Services at all (Huawei, bare
79
+ // AOSP). Not an error to retry — it will never succeed on this device.
80
+ // Such orgs should build with `myazaKycBundledMlKit = true`.
81
+ latch.countDown()
82
+ }
83
+ latch.await(MODEL_CHECK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
84
+ } catch (_: Throwable) {
85
+ // Leave `ready` as it was; the shared gate's wait decides when to stop.
86
+ }
87
+ return ready
88
+ }
89
+
90
+ /** Re-asks Play Services in the background; at most one check at a time. */
91
+ private fun refresh() {
92
+ if (checking) return
93
+ val ctx = NitroModules.applicationContext ?: return
94
+ checking = true
95
+ try {
96
+ ModuleInstall.getClient(ctx).areModulesAvailable(api)
97
+ .addOnSuccessListener { response -> if (response.areModulesAvailable()) ready = true }
98
+ .addOnCompleteListener { checking = false }
99
+ } catch (_: Throwable) {
100
+ checking = false
101
+ }
102
+ }
103
+
104
+ private fun requestInstall(client: ModuleInstallClient) {
105
+ if (installing) return
106
+ installing = true
107
+
108
+ val listener = object : InstallStatusListener {
109
+ override fun onInstallStatusUpdated(update: ModuleInstallStatusUpdate) {
110
+ when (update.installState) {
111
+ ModuleInstallStatusUpdate.InstallState.STATE_COMPLETED -> {
112
+ ready = true
113
+ installing = false
114
+ client.unregisterListener(this)
115
+ }
116
+ ModuleInstallStatusUpdate.InstallState.STATE_FAILED,
117
+ ModuleInstallStatusUpdate.InstallState.STATE_CANCELED -> {
118
+ // Cleared so the next prepare() can ask again.
119
+ installing = false
120
+ client.unregisterListener(this)
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // `installModules` takes a ModuleInstallRequest; `deferredInstall` takes the
127
+ // OptionalModuleApi itself. Handing a request to the wrong one is how this
128
+ // file once stopped compiling.
129
+ val request = ModuleInstallRequest.Builder().addApi(api).setListener(listener).build()
130
+ client.installModules(request)
131
+ .addOnSuccessListener { response ->
132
+ // The listener never fires for a module that was already there.
133
+ if (response.areModulesAlreadyInstalled()) {
134
+ ready = true
135
+ installing = false
136
+ client.unregisterListener(listener)
137
+ }
138
+ }
139
+ .addOnFailureListener {
140
+ installing = false
141
+ client.unregisterListener(listener)
142
+ }
143
+ }
144
+
145
+ private companion object {
146
+ /**
147
+ * Bound on the availability query. It is a local Play Services call, not the
148
+ * model download — the download runs in the background afterwards. Generous
149
+ * enough for a cold Play Services process, short enough that a wedged one
150
+ * cannot stall flow start.
151
+ */
152
+ const val MODEL_CHECK_TIMEOUT_MS = 3_000L
153
+ }
154
+ }