@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.
- 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.constants.js +9 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +57 -19
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +69 -14
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +33 -4
- 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.constants.d.ts +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.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 +2 -2
- 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.constants.ts +9 -0
- package/src/Shared/Components/IdentityDocumentCamera.flows.ts +78 -21
- package/src/Shared/Components/IdentityDocumentCamera.tsx +83 -27
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +21 -0
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +36 -3
- 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
|
},
|
|
@@ -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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
}
|