@trustchex/react-native-sdk 1.495.10 → 1.495.12

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 (44) hide show
  1. package/android/build.gradle +10 -0
  2. package/android/libs/jj2000-5.2.jar +0 -0
  3. package/android/src/main/java/com/trustchex/reactnativesdk/ImageDecoderModule.kt +88 -0
  4. package/android/src/main/java/com/trustchex/reactnativesdk/Jp2PgxDecoder.kt +135 -0
  5. package/android/src/main/java/com/trustchex/reactnativesdk/TrustchexSDKPackage.kt +12 -0
  6. package/android/src/test/java/com/trustchex/reactnativesdk/Jp2PgxDecoderTest.kt +143 -0
  7. package/android/src/test/resources/jp2/gray.jp2 +0 -0
  8. package/android/src/test/resources/jp2/solid_color.j2k +0 -0
  9. package/android/src/test/resources/jp2/solid_color.jp2 +0 -0
  10. package/lib/module/Shared/Components/EIDScanner.js +8 -5
  11. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +9 -0
  12. package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +57 -19
  13. package/lib/module/Shared/Components/IdentityDocumentCamera.js +69 -14
  14. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +33 -4
  15. package/lib/module/Shared/EIDReader/eidReader.js +11 -9
  16. package/lib/module/Shared/Libs/jp2Decode.js +12 -11
  17. package/lib/module/Shared/Services/DataUploadService.js +16 -4
  18. package/lib/module/version.js +1 -1
  19. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  20. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts +1 -0
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +2 -2
  24. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +21 -0
  26. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +15 -0
  28. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  30. package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts +4 -3
  31. package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts.map +1 -1
  32. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  33. package/lib/typescript/src/version.d.ts +1 -1
  34. package/package.json +1 -1
  35. package/src/Shared/Components/EIDScanner.tsx +7 -5
  36. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +9 -0
  37. package/src/Shared/Components/IdentityDocumentCamera.flows.ts +78 -21
  38. package/src/Shared/Components/IdentityDocumentCamera.tsx +83 -27
  39. package/src/Shared/Components/IdentityDocumentCamera.types.ts +21 -0
  40. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +36 -3
  41. package/src/Shared/EIDReader/eidReader.ts +11 -9
  42. package/src/Shared/Libs/jp2Decode.ts +15 -11
  43. package/src/Shared/Services/DataUploadService.ts +41 -8
  44. package/src/version.ts +1 -1
@@ -92,6 +92,16 @@ dependencies {
92
92
 
93
93
  // OpenCV for hologram detection and image processing
94
94
  implementation 'org.opencv:opencv:4.12.0'
95
+
96
+ // Pure-Java JPEG2000 decoder (jj2000, edu.ucar 5.2) — vendored as a local jar
97
+ // so it resolves fully offline with no Maven coordinate. Android's image
98
+ // pipeline cannot decode JPEG2000, but passport chip portraits (DG2) are
99
+ // frequently JP2; this lets ImageDecoderModule decode them to JPEG for display.
100
+ // No java.awt / javax.imageio usage, so it runs on Android.
101
+ implementation files("libs/jj2000-5.2.jar")
102
+
103
+ // Plain-JVM unit tests for the framework-free JP2 decode logic (Jp2PgxDecoder).
104
+ testImplementation "junit:junit:4.13.2"
95
105
  }
96
106
 
97
107
  react {
Binary file
@@ -0,0 +1,88 @@
1
+ package com.trustchex.reactnativesdk
2
+
3
+ import android.graphics.Bitmap
4
+ import android.util.Base64
5
+ import com.facebook.react.bridge.Promise
6
+ import com.facebook.react.bridge.ReactApplicationContext
7
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
8
+ import com.facebook.react.bridge.ReactMethod
9
+ import java.io.ByteArrayOutputStream
10
+ import java.io.File
11
+
12
+ /**
13
+ * Decodes images that React Native's <Image> cannot render directly on Android.
14
+ *
15
+ * The driving case is the passport chip portrait (DG2), which is frequently
16
+ * stored as JPEG2000 (image/jp2). Android's Fresco/Skia image pipeline does NOT
17
+ * decode JPEG2000, so the chip photo showed as a blank/white placeholder. This
18
+ * module decodes the JP2 with the pure-Java jj2000 decoder (vendored jar, no
19
+ * NDK, no javax.imageio) and re-encodes it as JPEG so the JS <Image> can render
20
+ * it — mirroring the iOS ImageDecoderModule (which uses ImageIO).
21
+ *
22
+ * jj2000's [Decoder] is file-based and only writes raw PGX (one file per image
23
+ * component) headlessly — its PPM writer rejects many real codestreams. So we
24
+ * decode to "<base>-N.pgx" components and recombine: 3 components → RGB, 1 →
25
+ * grayscale. PGX samples may be >8-bit and big- or little-endian; we normalise
26
+ * to 8-bit. Handles both the JP2 container and a raw J2K codestream (DG2 ships
27
+ * as either).
28
+ */
29
+ class ImageDecoderModule(reactContext: ReactApplicationContext) :
30
+ ReactContextBaseJavaModule(reactContext) {
31
+
32
+ override fun getName(): String = NAME
33
+
34
+ /**
35
+ * Decode a base64 JPEG2000 image to a base64 JPEG. Resolves with the JPEG
36
+ * base64 string, or rejects on any failure (the JS caller falls back to the
37
+ * raw JP2, exactly as it does when the native module is unavailable).
38
+ */
39
+ @ReactMethod
40
+ fun decodeJp2ToJpeg(base64Jp2: String, promise: Promise) {
41
+ val cacheDir = reactApplicationContext.cacheDir
42
+ val token = System.nanoTime().toString()
43
+ val input = File(cacheDir, "jp2_decode_$token.jp2")
44
+ // jj2000 derives component filenames from this base ("<base>-1.pgx", …).
45
+ val outBase = File(cacheDir, "jp2_decode_$token.pgx")
46
+ try {
47
+ val jp2Bytes = Base64.decode(base64Jp2, Base64.DEFAULT)
48
+ ?: throw IllegalArgumentException("Could not base64-decode JP2 input")
49
+ input.writeBytes(jp2Bytes)
50
+
51
+ Jp2PgxDecoder.decodeToPgx(input, outBase)
52
+
53
+ val componentFiles = Jp2PgxDecoder.collectComponentFiles(outBase)
54
+ if (componentFiles.isEmpty()) {
55
+ throw IllegalStateException("jj2000 produced no component output")
56
+ }
57
+
58
+ val decoded = Jp2PgxDecoder.componentsToRgb(componentFiles.map { it.readBytes() })
59
+ val pixels = IntArray(decoded.rgb.size) { 0xFF000000.toInt() or decoded.rgb[it] }
60
+ val bitmap = Bitmap.createBitmap(
61
+ pixels,
62
+ decoded.width,
63
+ decoded.height,
64
+ Bitmap.Config.ARGB_8888
65
+ )
66
+ val jpeg = ByteArrayOutputStream().use { out ->
67
+ // Re-encode as baseline JPEG so the chip portrait is a format BOTH the
68
+ // on-screen <Image> AND the verification backend accept (the backend
69
+ // face-match expects JPEG/PNG, not raw JPEG2000).
70
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
71
+ out.toByteArray()
72
+ }
73
+ bitmap.recycle()
74
+
75
+ promise.resolve(Base64.encodeToString(jpeg, Base64.NO_WRAP))
76
+ } catch (e: Throwable) {
77
+ promise.reject("DECODE_FAILED", e.message, e)
78
+ } finally {
79
+ input.delete()
80
+ Jp2PgxDecoder.collectComponentFiles(outBase).forEach { it.delete() }
81
+ outBase.delete()
82
+ }
83
+ }
84
+
85
+ companion object {
86
+ const val NAME = "ImageDecoderModule"
87
+ }
88
+ }
@@ -0,0 +1,135 @@
1
+ package com.trustchex.reactnativesdk
2
+
3
+ import jj2000.j2k.decoder.Decoder
4
+ import jj2000.j2k.util.ParameterList
5
+ import java.io.File
6
+
7
+ /**
8
+ * Pure (Android-free) half of the JPEG2000 decode: drive jj2000 to PGX
9
+ * components and turn those into a flat RGB pixel array. Kept separate from
10
+ * [ImageDecoderModule] so the parsing/recombination logic — the only novel,
11
+ * bug-prone part — is unit-testable on a plain JVM without Robolectric (no
12
+ * android.graphics.Bitmap / Color dependency here).
13
+ *
14
+ * jj2000's PGX output:
15
+ * - colour (multi-component): "<base>-1.pgx", "<base>-2.pgx", "<base>-3.pgx"
16
+ * - grayscale (single-component): "<base>.pgx" (no "-N" suffix)
17
+ * - each PGX: ASCII header `PG <ML|LM> [<+|->] <depth> <w> <h>\n` then raw
18
+ * big-/little-endian samples of `ceil(depth/8)` bytes each.
19
+ */
20
+ object Jp2PgxDecoder {
21
+
22
+ /** A decoded image as packed 0xRRGGBB ints plus its dimensions. */
23
+ class DecodedImage(val width: Int, val height: Int, val rgb: IntArray)
24
+
25
+ /** Run jj2000's file-based decoder, writing per-component PGX next to [outBase]. */
26
+ fun decodeToPgx(input: File, outBase: File) {
27
+ val defaults = ParameterList()
28
+ for (param in Decoder.getAllParameters()) {
29
+ // i / o have no default (null); everything else seeds the defaults list.
30
+ if (param[3] != null) defaults.put(param[0], param[3])
31
+ }
32
+ val pl = ParameterList(defaults).apply {
33
+ put("i", input.absolutePath)
34
+ put("o", outBase.absolutePath)
35
+ put("debug", "off")
36
+ put("verbose", "off")
37
+ }
38
+ Decoder(pl).run()
39
+ }
40
+
41
+ /**
42
+ * The decoded component PGX files in component order. Colour → "<base>-N.pgx"
43
+ * sorted by N; grayscale → the base "<base>.pgx" itself.
44
+ */
45
+ fun collectComponentFiles(outBase: File): List<File> {
46
+ val dir = outBase.parentFile ?: return emptyList()
47
+ val stem = outBase.name.removeSuffix(".pgx")
48
+ val multi = (dir.listFiles() ?: emptyArray())
49
+ .filter { it.name.startsWith("$stem-") && it.name.endsWith(".pgx") }
50
+ .sortedBy {
51
+ it.name.removePrefix("$stem-").removeSuffix(".pgx").toIntOrNull() ?: 0
52
+ }
53
+ if (multi.isNotEmpty()) return multi
54
+ return if (outBase.exists() && outBase.length() > 0) listOf(outBase) else emptyList()
55
+ }
56
+
57
+ /** Recombine component PGX byte arrays into a packed-RGB image. */
58
+ fun componentsToRgb(components: List<ByteArray>): DecodedImage {
59
+ require(components.isNotEmpty()) { "No PGX components" }
60
+ val comps = components.map { parsePgx(it) }
61
+ val width = comps[0].width
62
+ val height = comps[0].height
63
+ require(width > 0 && height > 0) { "Invalid PGX dimensions ${width}x$height" }
64
+
65
+ val rgb = IntArray(width * height)
66
+ val n = comps.size
67
+ for (idx in 0 until width * height) {
68
+ val r: Int
69
+ val g: Int
70
+ val b: Int
71
+ if (n >= 3) {
72
+ r = comps[0].sampleAt(idx)
73
+ g = comps[1].sampleAt(idx)
74
+ b = comps[2].sampleAt(idx)
75
+ } else {
76
+ val v = comps[0].sampleAt(idx)
77
+ r = v; g = v; b = v
78
+ }
79
+ rgb[idx] = (r shl 16) or (g shl 8) or b
80
+ }
81
+ return DecodedImage(width, height, rgb)
82
+ }
83
+
84
+ internal class Pgx(
85
+ val width: Int,
86
+ val height: Int,
87
+ val depth: Int,
88
+ val bigEndian: Boolean,
89
+ val dataOffset: Int,
90
+ val bytes: ByteArray
91
+ ) {
92
+ /** One normalised 8-bit sample at pixel index [idx]. */
93
+ fun sampleAt(idx: Int): Int {
94
+ val bytesPer = (depth + 7) / 8
95
+ val p = dataOffset + idx * bytesPer
96
+ var v = if (bytesPer <= 1) {
97
+ bytes[p].toInt() and 0xFF
98
+ } else {
99
+ val a = bytes[p].toInt() and 0xFF
100
+ val b = bytes[p + 1].toInt() and 0xFF
101
+ if (bigEndian) (a shl 8) or b else (b shl 8) or a
102
+ }
103
+ if (depth > 8) v = v shr (depth - 8) else if (depth < 8) v = v shl (8 - depth)
104
+ return v and 0xFF
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Parse a PGX header: "PG <ML|LM> [<+|->] <depth> <width> <height>\n" followed
110
+ * by raw samples. The sign and the leading '+'/'-' are optional and may be
111
+ * glued to the depth ("+16"); we tolerate both. "ML" = big-endian.
112
+ */
113
+ internal fun parsePgx(d: ByteArray): Pgx {
114
+ var i = 0
115
+ val sb = StringBuilder()
116
+ while (i < d.size && d[i].toInt().toChar() != '\n') {
117
+ sb.append((d[i].toInt() and 0xFF).toChar())
118
+ i++
119
+ }
120
+ val dataOffset = i + 1
121
+ val raw = sb.toString().trim().split(Regex("\\s+"))
122
+ val bigEndian = raw.getOrNull(1) == "ML"
123
+ val nums = ArrayList<Int>()
124
+ for (k in 2 until raw.size) {
125
+ var s = raw[k]
126
+ if (s == "+" || s == "-") continue
127
+ s = s.removePrefix("+").removePrefix("-")
128
+ s.toIntOrNull()?.let { nums.add(it) }
129
+ }
130
+ val depth = nums.getOrNull(0) ?: 8
131
+ val width = nums.getOrNull(1) ?: 0
132
+ val height = nums.getOrNull(2) ?: 0
133
+ return Pgx(width, height, depth, bigEndian, dataOffset, d)
134
+ }
135
+ }
@@ -18,6 +18,7 @@ class TrustchexSDKPackage : BaseReactPackage() {
18
18
  "DeviceBrightness" -> DeviceBrightnessModule(reactContext)
19
19
  "MLKitModule" -> MLKitModule(reactContext)
20
20
  "OpenCVModule" -> OpenCVModule(reactContext)
21
+ ImageDecoderModule.NAME -> ImageDecoderModule(reactContext)
21
22
  else -> null
22
23
  }
23
24
  }
@@ -74,6 +75,17 @@ class TrustchexSDKPackage : BaseReactPackage() {
74
75
  false // isTurboModule
75
76
  )
76
77
 
78
+ // JPEG2000 image decoder module (passport DG2 portrait → JPEG)
79
+ moduleInfos[ImageDecoderModule.NAME] =
80
+ ReactModuleInfo(
81
+ ImageDecoderModule.NAME,
82
+ ImageDecoderModule.NAME,
83
+ false, // canOverrideExistingModule
84
+ false, // needsEagerInit
85
+ false, // isCxxModule
86
+ false // isTurboModule
87
+ )
88
+
77
89
  moduleInfos
78
90
  }
79
91
  }
@@ -0,0 +1,143 @@
1
+ package com.trustchex.reactnativesdk
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+ import java.io.File
7
+ import java.nio.file.Files
8
+
9
+ /**
10
+ * Plain-JVM tests for the JPEG2000 → RGB decode logic that backs the Android
11
+ * ImageDecoderModule (the fix for the "white face" on German passports).
12
+ *
13
+ * Runs the real vendored jj2000 decoder against committed JP2 fixtures and
14
+ * verifies the recombined pixels, plus the PGX header parsing edge cases. No
15
+ * android.graphics dependency, so no Robolectric is needed.
16
+ *
17
+ * Fixtures (android/src/test/resources/jp2):
18
+ * - solid_color.jp2 : 64×64, every pixel rgb(200,40,60) [JP2 container, colour]
19
+ * - solid_color.j2k : same image as a raw J2K codestream (DG2 ships as either)
20
+ * - gray.jp2 : 64×64 black→white horizontal gradient [single-component]
21
+ */
22
+ class Jp2PgxDecoderTest {
23
+
24
+ private fun fixture(name: String): File {
25
+ val url = javaClass.classLoader!!.getResource("jp2/$name")
26
+ ?: error("Missing test fixture jp2/$name")
27
+ return File(url.toURI())
28
+ }
29
+
30
+ /** Decode a fixture end-to-end through the production code path. */
31
+ private fun decode(fixtureName: String): Jp2PgxDecoder.DecodedImage {
32
+ val tmpDir = Files.createTempDirectory("jp2test").toFile()
33
+ try {
34
+ val input = File(tmpDir, "in.jp2").apply { writeBytes(fixture(fixtureName).readBytes()) }
35
+ val outBase = File(tmpDir, "out.pgx")
36
+ Jp2PgxDecoder.decodeToPgx(input, outBase)
37
+ val comps = Jp2PgxDecoder.collectComponentFiles(outBase)
38
+ assertTrue("expected at least one component for $fixtureName", comps.isNotEmpty())
39
+ return Jp2PgxDecoder.componentsToRgb(comps.map { it.readBytes() })
40
+ } finally {
41
+ tmpDir.deleteRecursively()
42
+ }
43
+ }
44
+
45
+ private fun Jp2PgxDecoder.DecodedImage.pixelAt(x: Int, y: Int) = rgb[y * width + x]
46
+ private fun r(p: Int) = (p shr 16) and 0xFF
47
+ private fun g(p: Int) = (p shr 8) and 0xFF
48
+ private fun b(p: Int) = p and 0xFF
49
+
50
+ @Test
51
+ fun decodesColorJp2ContainerToCorrectRgb() {
52
+ val img = decode("solid_color.jp2")
53
+ assertEquals(64, img.width)
54
+ assertEquals(64, img.height)
55
+ // Every pixel is the same solid colour; check a few spread-out ones. JP2 is
56
+ // lossy so allow a small tolerance.
57
+ for ((x, y) in listOf(0 to 0, 63 to 0, 0 to 63, 63 to 63, 32 to 32)) {
58
+ val p = img.pixelAt(x, y)
59
+ assertClose("R@($x,$y)", 200, r(p))
60
+ assertClose("G@($x,$y)", 40, g(p))
61
+ assertClose("B@($x,$y)", 60, b(p))
62
+ }
63
+ }
64
+
65
+ @Test
66
+ fun decodesRawJ2kCodestreamSameAsContainer() {
67
+ // DG2 may be a bare codestream rather than a .jp2 box; must decode identically.
68
+ val img = decode("solid_color.j2k")
69
+ assertEquals(64, img.width)
70
+ val p = img.pixelAt(10, 10)
71
+ assertClose("R", 200, r(p))
72
+ assertClose("G", 40, g(p))
73
+ assertClose("B", 60, b(p))
74
+ }
75
+
76
+ @Test
77
+ fun decodesGrayscaleJp2AsSingleComponentToGray() {
78
+ // Single-component output is written as "<base>.pgx" with no "-N" suffix; the
79
+ // collector must still find it, and grayscale must expand to R==G==B.
80
+ val img = decode("gray.jp2")
81
+ assertEquals(64, img.width)
82
+ // ImageMagick `gradient:black-white` is a VERTICAL ramp (top dark → bottom
83
+ // bright), constant across each row. Check top vs bottom, and that every
84
+ // channel is equal (true grayscale expansion).
85
+ val top = img.pixelAt(32, 1)
86
+ val bottom = img.pixelAt(32, 62)
87
+ assertEquals("top should be gray", r(top), g(top))
88
+ assertEquals("top should be gray", g(top), b(top))
89
+ assertEquals("bottom should be gray", r(bottom), g(bottom))
90
+ assertTrue("gradient should brighten top→bottom", r(bottom) > r(top))
91
+ }
92
+
93
+ @Test
94
+ fun parsesBigEndian16BitPgxAndScalesTo8Bit() {
95
+ // "PG ML + 16 2 1\n" then one 16-bit big-endian sample 0xFF00 (=65280) and
96
+ // one 0x0100 (=256). depth 16 → >>8 → 255 and 1.
97
+ val header = "PG ML + 16 2 1\n".toByteArray(Charsets.US_ASCII)
98
+ val body = byteArrayOf(0xFF.toByte(), 0x00, 0x01, 0x00)
99
+ val pgx = Jp2PgxDecoder.parsePgx(header + body)
100
+ assertEquals(2, pgx.width)
101
+ assertEquals(1, pgx.height)
102
+ assertEquals(255, pgx.sampleAt(0))
103
+ assertEquals(1, pgx.sampleAt(1))
104
+ }
105
+
106
+ @Test
107
+ fun parsesLittleEndian8BitPgx() {
108
+ // "PG LM + 8 3 1\n" then bytes 10, 20, 30 — 8-bit, no scaling.
109
+ val header = "PG LM + 8 3 1\n".toByteArray(Charsets.US_ASCII)
110
+ val body = byteArrayOf(10, 20, 30)
111
+ val pgx = Jp2PgxDecoder.parsePgx(header + body)
112
+ assertEquals(10, pgx.sampleAt(0))
113
+ assertEquals(20, pgx.sampleAt(1))
114
+ assertEquals(30, pgx.sampleAt(2))
115
+ }
116
+
117
+ @Test
118
+ fun parsesHeaderWithSignGluedToDepth() {
119
+ // Some writers emit "+16" with no space; the parser must still read depth 16.
120
+ val header = "PG ML +16 1 1\n".toByteArray(Charsets.US_ASCII)
121
+ val body = byteArrayOf(0x80.toByte(), 0x00) // 0x8000 >> 8 = 128
122
+ val pgx = Jp2PgxDecoder.parsePgx(header + body)
123
+ assertEquals(128, pgx.sampleAt(0))
124
+ }
125
+
126
+ @Test
127
+ fun grayscaleComponentExpandsToEqualRgbChannels() {
128
+ val header = "PG ML + 8 1 1\n".toByteArray(Charsets.US_ASCII)
129
+ val body = byteArrayOf(123)
130
+ val img = Jp2PgxDecoder.componentsToRgb(listOf(header + body))
131
+ val p = img.rgb[0]
132
+ assertEquals(123, r(p))
133
+ assertEquals(123, g(p))
134
+ assertEquals(123, b(p))
135
+ }
136
+
137
+ private fun assertClose(label: String, expected: Int, actual: Int, tol: Int = 12) {
138
+ assertTrue(
139
+ "$label expected ~$expected (±$tol) but was $actual",
140
+ kotlin.math.abs(expected - actual) <= tol
141
+ )
142
+ }
143
+ }
@@ -458,11 +458,14 @@ const EIDScanner = ({
458
458
  style: styles.idCardPhotoContainer,
459
459
  children: /*#__PURE__*/_jsx(View, {
460
460
  style: styles.idCardPhotoFrame,
461
- children: documentFaceImage && (documentFaceImageMimeType === 'image/jpeg' || documentFaceImageMimeType === 'image/png' ||
462
- // Android's <Image> decodes a raw JP2 data URI; on iOS
463
- // the chip JP2 is converted to JPEG before this point
464
- // (native ImageIO), so iOS never reaches here as jp2.
465
- documentFaceImageMimeType === 'image/jp2') ? /*#__PURE__*/_jsx(Image, {
461
+ children: documentFaceImage && (
462
+ // The chip portrait (DG2) is JPEG2000, which RN's <Image>
463
+ // can't render on either platform the native decoder
464
+ // (iOS ImageIO / Android jj2000) converts it to JPEG (or
465
+ // the small-image JS fallback to PNG) before this point.
466
+ // A raw image/jp2 would render as a blank box, so we only
467
+ // accept the decoded formats and otherwise show '?'.
468
+ documentFaceImageMimeType === 'image/jpeg' || documentFaceImageMimeType === 'image/png') ? /*#__PURE__*/_jsx(Image, {
466
469
  source: {
467
470
  uri: `data:${documentFaceImageMimeType};base64,${documentFaceImage}`
468
471
  },
@@ -17,6 +17,15 @@ export const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3;
17
17
  export const SIGNATURE_REGEX = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i;
18
18
  export const SIGNATURE_TEXT_REGEX = /signature|imza|İmza/i;
19
19
  export const MRZ_BLOCK_PATTERN = /[A-Z0-9<]{8,}.*</i;
20
+ // A DENSE MRZ run — a contiguous block of MRZ characters that contains a chevron
21
+ // FILLER pair (`<<`). Real machine-readable zones are padded with `<<…`; a card
22
+ // FACE (ID front) has no MRZ, and the occasional stray single `<` OCR'd from a
23
+ // guilloche/chevron glyph will NOT contain a `<<` filler run. Used to decide
24
+ // "is a real MRZ visible on this front?" without the false positives of
25
+ // MRZ_BLOCK_PATTERN (whose `.*<` lets a lone detached `<` anywhere in the frame
26
+ // match) — that looseness could otherwise wedge a legitimate ID front that
27
+ // happens to OCR a stray `<`.
28
+ export const MRZ_DENSE_PATTERN = /[A-Z0-9<]{6,}<<[A-Z0-9<]*/i;
20
29
  // Matches the line-1 start of ANY ICAO TD3 passport MRZ, so the camera routes
21
30
  // every passport variant into the passport flow (not the ID-card back-side
22
31
  // flow). Per ICAO 9303 Part 4: `P` + a type char (filler `<` or a subtype
@@ -30,8 +30,8 @@
30
30
  * - No face, has MRZ with code 'I'
31
31
  */
32
32
 
33
- import { PASSPORT_MRZ_PATTERN } from "./IdentityDocumentCamera.constants.js";
34
- import { isIDCardDocumentCode } from "./IdentityDocumentCamera.utils.js";
33
+ import { PASSPORT_MRZ_PATTERN, MRZ_DENSE_PATTERN } from "./IdentityDocumentCamera.constants.js";
34
+ import { isIDCardDocumentCode, isPassportDocumentCode, documentHasBackSide } from "./IdentityDocumentCamera.utils.js";
35
35
 
36
36
  /**
37
37
  * After this many retry attempts for ID_FRONT flow, proceed with face detection alone
@@ -142,8 +142,15 @@ export function handleIDFrontFlow(faces, text, mrzText, mrzFields, retryCount) {
142
142
  nextAction: 'REJECT_AS_PASSPORT'
143
143
  };
144
144
  }
145
- if (mrzText && PASSPORT_MRZ_PATTERN.test(mrzText)) {
146
- // Passport MRZ pattern visible even if not fully parsed
145
+
146
+ // Passport MRZ pattern visible even if not fully parsed. Check BOTH the
147
+ // parsed `mrzText` (null until the MRZ validates) AND the raw OCR `text` — a
148
+ // passport's MRZ is dense and frequently OCRs a few frames after its face +
149
+ // signature, so on the accepting frame `mrzText` is often still null while the
150
+ // raw `P<XXX...` run is already visible. Testing only `mrzText` (the old bug)
151
+ // let such a passport pass as an ID front and get routed to a back side that
152
+ // does not exist.
153
+ if (mrzText && PASSPORT_MRZ_PATTERN.test(mrzText) || PASSPORT_MRZ_PATTERN.test(text)) {
147
154
  return {
148
155
  shouldProceed: false,
149
156
  reason: 'Passport MRZ pattern visible - not an ID card',
@@ -163,6 +170,34 @@ export function handleIDFrontFlow(faces, text, mrzText, mrzFields, retryCount) {
163
170
  };
164
171
  }
165
172
 
173
+ // ============================================================================
174
+ // STEP 2b: An ID-card FRONT has NO MRZ (the MRZ is on the back). So if a real
175
+ // MRZ is currently visible on this "front", it is almost certainly a passport
176
+ // data page whose 'P' code we just haven't parsed yet — do NOT accept it as an
177
+ // ID front; wait for the MRZ to resolve (→ a 'P' code routes it to the
178
+ // passport flow). This is the guard that stops a passport (face + signature +
179
+ // not-yet-parsed MRZ) being accepted as ID_FRONT and then getting stuck at
180
+ // SCAN_ID_BACK, which rejects the passport's photo page because it has a face.
181
+ //
182
+ // We use MRZ_DENSE_PATTERN (requires a `<<` filler run), NOT the loose
183
+ // MRZ_BLOCK_PATTERN — a card face has no MRZ, but a stray single `<` OCR'd
184
+ // from a guilloche could match the loose pattern and wedge a real ID front.
185
+ // We ALSO honor the signature retry threshold: a real ID front genuinely has
186
+ // no MRZ, so if a "dense MRZ" keeps matching frame after frame past the
187
+ // threshold it is OCR noise, not a passport — fall through and let STEP 3's
188
+ // face-only acceptance proceed rather than waiting forever.
189
+ const mrzVisibleOnFront = !!mrzText || MRZ_DENSE_PATTERN.test(text);
190
+ const stillWaitingForMrz = retryCount <= SIGNATURE_RETRY_THRESHOLD;
191
+ if (mrzVisibleOnFront && stillWaitingForMrz && !isIDCardDocumentCode(mrzFields?.documentCode)) {
192
+ // An ID-card MRZ code (I/A/C) is allowed to proceed (handled in STEP 4); any
193
+ // other visible MRZ → keep waiting for it to parse and reveal a 'P' code.
194
+ return {
195
+ shouldProceed: false,
196
+ reason: 'MRZ block visible on front - waiting to confirm it is not a passport',
197
+ nextAction: 'WAIT_FOR_MRZ'
198
+ };
199
+ }
200
+
166
201
  // ============================================================================
167
202
  // STEP 3: Check for signature (optional front side confirmation)
168
203
  // ============================================================================
@@ -283,20 +318,23 @@ export function handleIDBackFlow(mrzText, mrzFields, mrzValid, mrzStableAndValid
283
318
  * self-corrects: SCAN_ID_BACK simply won't find an ID-back MRZ/barcode and the
284
319
  * user can cancel, which is far better than skipping the back of every real ID.
285
320
  */
286
- export function getNextStepAfterHologram(detectedDocumentType, currentFrameDocType, mrzDocCode) {
287
- // Any positive passport signal done (no back side).
288
- const isPassport = detectedDocumentType === 'PASSPORT' || currentFrameDocType === 'PASSPORT' || mrzDocCode === 'P';
289
- if (isPassport) {
290
- return 'COMPLETED';
291
- }
292
-
293
- // Positive ID-card evidence scan the back. Either a non-'P' MRZ code, or a
294
- // front classified as ID_FRONT (an ID front legitimately has no MRZ, so the
295
- // classification is the only signal we get).
296
- const hasIdCardMrzCode = !!mrzDocCode && mrzDocCode !== 'P';
321
+ export function getNextStepAfterHologram(detectedDocumentType, currentFrameDocType, mrzDocCode,
322
+ // True if a passport MRZ pattern / 'P' code was seen in ANY front frame this
323
+ // session not just the accepting one. The passport MRZ is dense and often
324
+ // OCRs a few frames AFTER the face + signature, so a passport can get locked
325
+ // as ID_FRONT before its MRZ is read. Latching the signal across frames is
326
+ // what stops that passport from being wrongly routed to SCAN_ID_BACK here.
327
+ sawPassportSignal = false) {
328
+ // Resolve to a single document type, passport-biased: any positive passport
329
+ // signal wins (a passport must never wait for a back that does not exist),
330
+ // then positive ID-card evidence (a non-'P' MRZ code, or an ID_FRONT
331
+ // classification an ID front legitimately has no MRZ, so the classification
332
+ // is the only signal we get), else UNKNOWN.
333
+ const isPassport = sawPassportSignal || detectedDocumentType === 'PASSPORT' || currentFrameDocType === 'PASSPORT' || isPassportDocumentCode(mrzDocCode);
334
+ const hasIdCardMrzCode = !!mrzDocCode && !isPassportDocumentCode(mrzDocCode);
297
335
  const isIdFront = detectedDocumentType === 'ID_FRONT' || currentFrameDocType === 'ID_FRONT';
298
- if (hasIdCardMrzCode || isIdFront) {
299
- return 'SCAN_ID_BACK';
300
- }
301
- return 'COMPLETED';
336
+ const resolved = isPassport ? 'PASSPORT' : hasIdCardMrzCode || isIdFront ? 'ID_FRONT' : 'UNKNOWN';
337
+
338
+ // Single source of truth for "is there a back side left to scan?".
339
+ return documentHasBackSide(resolved) ? 'SCAN_ID_BACK' : 'COMPLETED';
302
340
  }