@trustchex/react-native-sdk 1.495.9 → 1.495.11

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 (41) 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.flows.js +19 -16
  12. package/lib/module/Shared/Components/IdentityDocumentCamera.js +43 -4
  13. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +22 -0
  14. package/lib/module/Shared/EIDReader/eidReader.js +11 -9
  15. package/lib/module/Shared/Libs/jp2Decode.js +12 -11
  16. package/lib/module/Shared/Services/DataUploadService.js +16 -4
  17. package/lib/module/version.js +1 -1
  18. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  19. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  20. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +1 -1
  21. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -1
  22. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +21 -0
  23. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
  24. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +15 -0
  25. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  26. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  27. package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts +4 -3
  28. package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
  30. package/lib/typescript/src/version.d.ts +1 -1
  31. package/lib/typescript/src/version.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/Shared/Components/EIDScanner.tsx +7 -5
  34. package/src/Shared/Components/IdentityDocumentCamera.flows.ts +29 -18
  35. package/src/Shared/Components/IdentityDocumentCamera.tsx +48 -3
  36. package/src/Shared/Components/IdentityDocumentCamera.types.ts +21 -0
  37. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +24 -0
  38. package/src/Shared/EIDReader/eidReader.ts +11 -9
  39. package/src/Shared/Libs/jp2Decode.ts +15 -11
  40. package/src/Shared/Services/DataUploadService.ts +41 -8
  41. 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
  },
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import { PASSPORT_MRZ_PATTERN } from "./IdentityDocumentCamera.constants.js";
34
- import { isIDCardDocumentCode } from "./IdentityDocumentCamera.utils.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
@@ -283,20 +283,23 @@ export function handleIDBackFlow(mrzText, mrzFields, mrzValid, mrzStableAndValid
283
283
  * self-corrects: SCAN_ID_BACK simply won't find an ID-back MRZ/barcode and the
284
284
  * user can cancel, which is far better than skipping the back of every real ID.
285
285
  */
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';
286
+ export function getNextStepAfterHologram(detectedDocumentType, currentFrameDocType, mrzDocCode,
287
+ // True if a passport MRZ pattern / 'P' code was seen in ANY front frame this
288
+ // session not just the accepting one. The passport MRZ is dense and often
289
+ // OCRs a few frames AFTER the face + signature, so a passport can get locked
290
+ // as ID_FRONT before its MRZ is read. Latching the signal across frames is
291
+ // what stops that passport from being wrongly routed to SCAN_ID_BACK here.
292
+ sawPassportSignal = false) {
293
+ // Resolve to a single document type, passport-biased: any positive passport
294
+ // signal wins (a passport must never wait for a back that does not exist),
295
+ // then positive ID-card evidence (a non-'P' MRZ code, or an ID_FRONT
296
+ // classification an ID front legitimately has no MRZ, so the classification
297
+ // is the only signal we get), else UNKNOWN.
298
+ const isPassport = sawPassportSignal || detectedDocumentType === 'PASSPORT' || currentFrameDocType === 'PASSPORT' || isPassportDocumentCode(mrzDocCode);
299
+ const hasIdCardMrzCode = !!mrzDocCode && !isPassportDocumentCode(mrzDocCode);
297
300
  const isIdFront = detectedDocumentType === 'ID_FRONT' || currentFrameDocType === 'ID_FRONT';
298
- if (hasIdCardMrzCode || isIdFront) {
299
- return 'SCAN_ID_BACK';
300
- }
301
- return 'COMPLETED';
301
+ const resolved = isPassport ? 'PASSPORT' : hasIdCardMrzCode || isIdFront ? 'ID_FRONT' : 'UNKNOWN';
302
+
303
+ // Single source of truth for "is there a back side left to scan?".
304
+ return documentHasBackSide(resolved) ? 'SCAN_ID_BACK' : 'COMPLETED';
302
305
  }
@@ -21,7 +21,7 @@ import { speak, resetLastMessage } from "../Libs/tts.utils.js";
21
21
  import AppContext from "../Contexts/AppContext.js";
22
22
  import { useTheme } from "../Contexts/ThemeContext.js";
23
23
  import DebugOverlay, { TestModePanel } from "./DebugOverlay.js";
24
- import { getStatusMessage, getFrameToScreenTransform, transformBoundsToScreen, getScanAreaBounds, angleBetweenPoints, detectDocumentType, determineDocumentTypeToSet, areMRZFieldsEqual, hasRequiredMRZFields, validateFacePosition } from "./IdentityDocumentCamera.utils.js";
24
+ import { getStatusMessage, getFrameToScreenTransform, transformBoundsToScreen, getScanAreaBounds, angleBetweenPoints, detectDocumentType, determineDocumentTypeToSet, areMRZFieldsEqual, hasRequiredMRZFields, validateFacePosition, isPassportDocumentCode } from "./IdentityDocumentCamera.utils.js";
25
25
  import { handlePassportFlow, handleIDFrontFlow, handleIDBackFlow, getNextStepAfterHologram } from "./IdentityDocumentCamera.flows.js";
26
26
  import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, PASSPORT_MRZ_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
27
27
 
@@ -104,6 +104,15 @@ const IdentityDocumentCamera = ({
104
104
  const lastDetectedDocType = useRef('UNKNOWN');
105
105
  const consistentDocTypeCount = useRef(0);
106
106
 
107
+ // Latches true the moment a passport MRZ pattern / 'P' document code is seen in
108
+ // ANY front frame this session. The passport MRZ is dense and often OCRs a few
109
+ // frames AFTER the face + signature, so without this a passport can be locked
110
+ // as ID_FRONT (which has no MRZ on its front) and then wrongly asked for a back
111
+ // side that does not exist. Once latched, the front is never accepted as
112
+ // ID_FRONT and the post-hologram routing always completes instead of scanning
113
+ // a back. Reset only when a brand-new scan session starts.
114
+ const sawPassportSignal = useRef(false);
115
+
107
116
  // Frame quality tracking - persist across callbacks
108
117
  const lastFrameQuality = useRef({
109
118
  hasAcceptableQuality: true,
@@ -197,6 +206,7 @@ const IdentityDocumentCamera = ({
197
206
  lastValidMRZText.current = null;
198
207
  lastValidMRZFields.current = null;
199
208
  validMRZConsecutiveCount.current = 0;
209
+ sawPassportSignal.current = false;
200
210
  mrzAggregator.current.reset();
201
211
  setMrzReliable(false);
202
212
  // Start a fresh diagnostics session for the support report.
@@ -441,6 +451,15 @@ const IdentityDocumentCamera = ({
441
451
 
442
452
  // Reset MRZ retry counter for each new step so retries start fresh
443
453
  mrzDetectionCurrentRetryCount.current = 0;
454
+ // A transition back to the initial front step means a brand-new document
455
+ // scan is starting, so the sticky passport signal from any previous
456
+ // document must NOT carry over — otherwise an ID card scanned after a
457
+ // passport (without the camera unfocusing) would be forced into the
458
+ // passport flow and skip its back side. (The focus-based reset above only
459
+ // fires when the camera goes inactive.)
460
+ if (nextStepType === 'SCAN_ID_FRONT_OR_PASSPORT') {
461
+ sawPassportSignal.current = false;
462
+ }
444
463
  // Only clear MRZ text when entering SCAN_ID_BACK (new MRZ expected).
445
464
  // Preserve across SCAN_HOLOGRAM so passport completion has MRZ data.
446
465
  if (nextStepType === 'SCAN_ID_BACK') {
@@ -1195,7 +1214,7 @@ const IdentityDocumentCamera = ({
1195
1214
 
1196
1215
  // Use scannedData.mrzFields which we just ensured has preserved MRZ
1197
1216
  const mrzDocCode = scannedData.mrzFields?.documentCode;
1198
- const nextStepAfterHologram = getNextStepAfterHologram(detectedDocumentType, documentType, mrzDocCode);
1217
+ const nextStepAfterHologram = getNextStepAfterHologram(detectedDocumentType, documentType, mrzDocCode, sawPassportSignal.current);
1199
1218
  transitionStepWithCallback(nextStepAfterHologram, 'SCAN_HOLOGRAM', scannedData);
1200
1219
  return;
1201
1220
  }
@@ -1207,10 +1226,30 @@ const IdentityDocumentCamera = ({
1207
1226
  // INITIAL SCAN STEP - Detect document type and validate
1208
1227
  // ============================================================================
1209
1228
  if (nextStep === 'SCAN_ID_FRONT_OR_PASSPORT') {
1229
+ // Latch ANY passport signal seen in a front frame — the parsed 'P' code
1230
+ // OR the raw passport MRZ pattern (which survives even when full MRZ
1231
+ // parsing fails). The passport MRZ often OCRs a few frames after the
1232
+ // face + signature, so we remember it across frames; once latched, this
1233
+ // front can never be accepted as an ID card / asked for a back side.
1234
+ // Only AUTHORITATIVE passport signals latch — a parsed MRZ document code
1235
+ // of 'P', or a settled PASSPORT classification. We deliberately do NOT
1236
+ // latch on a raw PASSPORT_MRZ_PATTERN substring match against OCR text:
1237
+ // that loose test can fire on ID-card MRZ noise, and because the latch is
1238
+ // sticky and one-directional a single false match would force the
1239
+ // passport flow for the rest of the session and silently skip the ID
1240
+ // card's back side. The parsed 'P' code still covers the lagging-MRZ case
1241
+ // (it just requires the MRZ to have actually parsed first).
1242
+ if (isPassportDocumentCode(parsedMRZData?.fields?.documentCode) || documentType === 'PASSPORT' || detectedDocumentType === 'PASSPORT') {
1243
+ sawPassportSignal.current = true;
1244
+ }
1245
+
1210
1246
  // Determine which flow handler to use.
1211
1247
  // Current-frame passport detection always takes precedence over a locked
1212
- // ID_FRONT — passport MRZ may not appear until later frames.
1213
- const flowDocumentType = documentType === 'PASSPORT' ? 'PASSPORT' : detectedDocumentType !== 'UNKNOWN' ? detectedDocumentType : documentType;
1248
+ // ID_FRONT — passport MRZ may not appear until later frames. A latched
1249
+ // passport signal also forces the passport flow, so a passport that was
1250
+ // momentarily locked as ID_FRONT (before its MRZ was read) is corrected
1251
+ // instead of being driven into the ID-card back-side flow.
1252
+ const flowDocumentType = documentType === 'PASSPORT' || sawPassportSignal.current ? 'PASSPORT' : detectedDocumentType !== 'UNKNOWN' ? detectedDocumentType : documentType;
1214
1253
  const handlePassportInitialStep = async () => {
1215
1254
  const flowResult = handlePassportFlow(primaryFaces, mrzText, parsedMRZData?.fields, mrzStableAndValid, onlyMRZScan, hasRequiredMRZFields(parsedMRZData?.fields), !!faceImageToUse);
1216
1255
  if (!flowResult.shouldProceed) {
@@ -13,6 +13,28 @@ export function isIDCardDocumentCode(code) {
13
13
  return code.startsWith('I') || code.startsWith('A') || code.startsWith('C');
14
14
  }
15
15
 
16
+ /**
17
+ * Checks if a document code represents a passport (ICAO TD3).
18
+ * Per ICAO 9303 Part 4, the first character of a passport MRZ document code is
19
+ * 'P' — for the ordinary `P<`, the German `PP`, and the diplomatic/service/
20
+ * official `PD`/`PS`/`PO` variants alike.
21
+ */
22
+ export function isPassportDocumentCode(code) {
23
+ if (!code) return false;
24
+ return code.startsWith('P');
25
+ }
26
+
27
+ /**
28
+ * Single source of truth for "does this document have a second side left to
29
+ * scan?". A passport's data all lives on one page, so it has no back; an ID
30
+ * card's MRZ lives on the back, so an ID front always does. Used by the
31
+ * post-front / post-hologram routing so the "ask for the back side?" decision
32
+ * is never re-derived ad-hoc (the bug where passports were sent to SCAN_ID_BACK).
33
+ */
34
+ export function documentHasBackSide(documentType) {
35
+ return documentType === 'ID_FRONT';
36
+ }
37
+
16
38
  /**
17
39
  * Frame-to-screen coordinate transform using FILL_CENTER scaling
18
40
  */
@@ -229,21 +229,23 @@ async function convertJP2IfNeeded(imageBuffer, mimeType) {
229
229
  };
230
230
  }
231
231
 
232
- // Prefer the native decoder (iOS ImageIO). It decodes JPEG2000 of any size
233
- // without blocking the JS thread, so it both fixes the iOS preview (RN's
234
- // <Image> can't render a raw image/jp2 data URI) and avoids the slow pure-JS
235
- // decoder entirely. Returns null on Android / when unavailable → fall through.
232
+ // Prefer the native decoder (iOS ImageIO / Android jj2000). It decodes
233
+ // JPEG2000 of any size off the JS thread, so it fixes the on-screen preview on
234
+ // BOTH platforms (RN's <Image> can't render a raw image/jp2 data URI on either)
235
+ // and avoids the slow pure-JS decoder entirely. Returns null only when the
236
+ // native module is unavailable/unlinked or it fails → fall through.
236
237
  const native = await decodeJp2ToJpeg(imageBuffer);
237
238
  if (native) {
238
239
  debugLog('EID', `[EID] JP2 decoded natively → ${native.mimeType} (${native.base64.length} base64 chars)`);
239
240
  return native;
240
241
  }
241
242
 
242
- // Too large to decode synchronously without freezing the UI keep the raw
243
- // JP2. The face image is still captured (the verification backend / face-match
244
- // accept JPEG2000); only the on-screen preview can't render it. This lets the
245
- // NFC read COMPLETE instead of hanging. (On Android the raw JP2 still renders
246
- // in <Image>; on iOS the native path above normally handles it.)
243
+ // Native decode unavailable AND too large to decode synchronously in JS
244
+ // without freezing the UI — keep the raw JP2. The face image is still captured
245
+ // (the verification backend / face-match accept JPEG2000); only the on-screen
246
+ // preview can't render it. This lets the NFC read COMPLETE instead of hanging.
247
+ // In normal operation the native path above handles this on both platforms; we
248
+ // only reach here if the native module failed or isn't linked.
247
249
  if (imageBuffer.length > MAX_JP2_DECODE_BYTES) {
248
250
  debugLog('EID', `[EID] JP2 face image is ${imageBuffer.length} bytes (> ${MAX_JP2_DECODE_BYTES}); skipping in-JS decode to avoid blocking, keeping raw JP2`);
249
251
  return {