@trustchex/react-native-sdk 1.495.10 → 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.
- package/android/build.gradle +10 -0
- package/android/libs/jj2000-5.2.jar +0 -0
- package/android/src/main/java/com/trustchex/reactnativesdk/ImageDecoderModule.kt +88 -0
- package/android/src/main/java/com/trustchex/reactnativesdk/Jp2PgxDecoder.kt +135 -0
- package/android/src/main/java/com/trustchex/reactnativesdk/TrustchexSDKPackage.kt +12 -0
- package/android/src/test/java/com/trustchex/reactnativesdk/Jp2PgxDecoderTest.kt +143 -0
- package/android/src/test/resources/jp2/gray.jp2 +0 -0
- package/android/src/test/resources/jp2/solid_color.j2k +0 -0
- package/android/src/test/resources/jp2/solid_color.jp2 +0 -0
- package/lib/module/Shared/Components/EIDScanner.js +8 -5
- package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +19 -16
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +43 -4
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +22 -0
- package/lib/module/Shared/EIDReader/eidReader.js +11 -9
- package/lib/module/Shared/Libs/jp2Decode.js +12 -11
- package/lib/module/Shared/Services/DataUploadService.js +16 -4
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +21 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +15 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
- package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts +4 -3
- package/lib/typescript/src/Shared/Libs/jp2Decode.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -1
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Shared/Components/EIDScanner.tsx +7 -5
- package/src/Shared/Components/IdentityDocumentCamera.flows.ts +29 -18
- package/src/Shared/Components/IdentityDocumentCamera.tsx +48 -3
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +21 -0
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +24 -0
- package/src/Shared/EIDReader/eidReader.ts +11 -9
- package/src/Shared/Libs/jp2Decode.ts +15 -11
- package/src/Shared/Services/DataUploadService.ts +41 -8
- package/src/version.ts +1 -1
package/android/build.gradle
CHANGED
|
@@ -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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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 && (
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// (
|
|
465
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
//
|
|
234
|
-
// <Image> can't render a raw image/jp2 data URI
|
|
235
|
-
// decoder entirely. Returns null
|
|
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
|
-
//
|
|
243
|
-
// JP2. The face image is still captured
|
|
244
|
-
// accept JPEG2000); only the on-screen
|
|
245
|
-
// NFC read COMPLETE instead of hanging.
|
|
246
|
-
//
|
|
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 {
|