@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.
- package/README.md +128 -4
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +57 -2
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +17 -8
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/Icon.tsx +8 -7
- package/src/components/KycFlow.tsx +9 -2
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/stepHeaderMeta.tsx +6 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/workflowMerge.ts +2 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/services/api-types.ts +28 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/kycStore.ts +8 -2
- package/src/types/config.ts +15 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- 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-
|
|
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)**.
|
package/android/build.gradle
CHANGED
|
@@ -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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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 =
|
|
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,
|
|
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
|
+
}
|