DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

Build an Android Passport Scanner with MRZ and Portrait Detection

Identity verification workflows — from airport check-in to hotel registration — still rely heavily on manual data entry from physical travel documents. Automating MRZ (Machine-Readable Zone) scanning eliminates typos, speeds up onboarding, and keeps sensitive data on-device. The Dynamsoft Capture Vision SDK bundles document detection, text recognition, code parsing, and identity processing into a single pipeline, making it practical to ship a production-grade scanner in an Android app.

What you'll build: A real-time Android MRZ scanner app that captures live camera frames, parses travel document fields, and validates portrait zones with the Dynamsoft Capture Vision SDK.

Watch the MRZ Scanner Demo

Prerequisites

  • Android Studio Hedgehog (2023.1.1) or newer
  • A device or emulator running Android 5.0 (API 21) or later
  • The Dynamsoft MRZ Scanner Bundle (com.dynamsoft:mrzscannerbundle:3.4.1200)

Get a 30-day free trial license

Step 1: Set Up the Android Project with the Dynamsoft SDK

Add the Dynamsoft Maven repository to your top-level build.gradle:

// build.gradle (project level)
allprojects {
    repositories {
        maven { url "https://download2.dynamsoft.com/maven/aar" }
        google()
        mavenCentral()
    }
}
Enter fullscreen mode Exit fullscreen mode

Declare the MRZ Scanner Bundle dependency in app/build.gradle. The bundle pulls in Camera Enhancer, Capture Vision Router, Document Normalizer, Label Recognizer, Code Parser, and Identity Processor — no need to add each module individually:

// app/build.gradle
plugins {
    id 'com.android.application'
}

android {
    namespace 'com.test.mrzscanner'
    compileSdk 34

    defaultConfig {
        applicationId "com.test.mrzscanner"
        minSdk 21
        targetSdk 34
        versionCode 2
        versionName "2.0"
    }

    buildFeatures {
        viewBinding true
    }

    packagingOptions {
        doNotStrip "**/*.so"
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'com.dynamsoft:mrzscannerbundle:3.4.1200'
    implementation 'com.google.android.material:material:1.11.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    implementation 'com.github.bumptech.glide:glide:4.16.0'
}
Enter fullscreen mode Exit fullscreen mode

The packagingOptions.doNotStrip block is important — the Dynamsoft SDK ships native .so libraries, and stripping them would cause a runtime crash.

Step 2: Initialize the Dynamsoft Capture Vision License

Before calling any SDK API, initialize your license. In this project the license key lives in MrzParser.java:

public class MrzParser {
    private static final String LICENSE_KEY = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==";

    public static void initLicense() {
        com.dynamsoft.license.LicenseManager.initLicense(LICENSE_KEY, (isSuccess, error) -> {
            if (!isSuccess) {
                android.util.Log.e("MrzParser", "License initialization failed: " + error.getMessage());
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Call MrzParser.initLicense() early in MainActivity.onCreate() before constructing any SDK components.

Replace the placeholder key with your own trial license from the Dynamsoft Customer Portal.

Step 3: Set Up the Camera Stream with Dynamsoft Camera Enhancer

MainActivity uses CameraEnhancer backed by a CameraView declared in XML:

<com.dynamsoft.dce.CameraView
    android:id="@+id/dce_camera_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
Enter fullscreen mode Exit fullscreen mode

In code, request the camera permission, create the CameraEnhancer, and set it as the input source for the CaptureVisionRouter:

public class MainActivity extends AppCompatActivity {
    private CameraEnhancer mCamera;
    private CameraView mCameraView;
    private final CaptureVisionRouter mRouter = new CaptureVisionRouter();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scan);

        mCameraView = findViewById(R.id.dce_camera_view);
        PermissionUtil.requestCameraPermission(this);

        mCamera = new CameraEnhancer(mCameraView, this);
        mCamera.setColourChannelUsageType(EnumColourChannelUsageType.CCUT_FULL_CHANNEL);
        try {
            mCamera.enableEnhancedFeatures(EnumEnhancerFeatures.EF_FRAME_FILTER);
        } catch (CameraEnhancerException ignore) {}

        // ...router setup goes here
        mRouter.setInput(mCamera);
    }

    @Override
    protected void onResume() {
        super.onResume();
        mCamera.open();
        mRouter.startCapturing("ReadPassportAndId", new CompletionListener() {
            @Override
            public void onSuccess() { /* ready */ }
            @Override
            public void onFailure(int code, String msg) { /* error */ }
        });
    }

    @Override
    protected void onPause() {
        super.onPause();
        mCamera.close();
        mRouter.stopCapturing();
    }
}
Enter fullscreen mode Exit fullscreen mode

The camera lifecycle is tied to onResume/onPause — open it when the activity gains focus and close it when focus is lost.

Step 4: Configure the MRZ Recognition Pipeline

The CaptureVisionRouter orchestrates the detection→recognition→parsing pipeline. Configuration is driven by a JSON template file bundled with the SDK:

MultiFrameResultCrossFilter filter = new MultiFrameResultCrossFilter();
filter.enableResultCrossVerification(
    EnumCapturedResultItemType.CRIT_TEXT_LINE
    | EnumCapturedResultItemType.CRIT_DESKEWED_IMAGE
    | EnumCapturedResultItemType.CRIT_DETECTED_QUAD,
    true
);
filter.setResultCrossVerificationCriteria(
    EnumCapturedResultItemType.CRIT_DESKEWED_IMAGE
    | EnumCapturedResultItemType.CRIT_DETECTED_QUAD,
    new CrossVerificationCriteria(5, 2)
);
mRouter.addResultFilter(filter);

mRouter.initSettingsFromFile("mrzscanner-mobile-templates.json");
mRouter.setInput(mCamera);

SimplifiedCaptureVisionSettings st = mRouter.getSimplifiedSettings("ReadPassportAndId");
if (st.documentSettings != null) {
    st.documentSettings.minQuadrilateralAreaRatio = 2;
}
mRouter.updateSettings("ReadPassportAndId", st);
Enter fullscreen mode Exit fullscreen mode

The MultiFrameResultCrossFilter requires the same result to be confirmed across multiple frames before it's emitted, suppressing flickering false positives. The template name "ReadPassportAndId" references a pre-built pipeline inside the JSON template that targets travel documents.

Android MRZ Scanner App

Step 5: Parse MRZ Output and Cache Results

Two receivers listen for output. The IntermediateResultReceiver caches intermediate units (scaled color image, localized text lines, recognized text, detected quads, deskewed image) that the IdentityProcessor needs later. The CapturedResultReceiver handles the parsed MRZ result:

private final CapturedResultReceiver mResultReceiver = new CapturedResultReceiver() {
    @Override
    public void onCapturedResultReceived(@NonNull CapturedResult result) {
        ParsedResult pr = result.getParsedResult();
        if (pr == null || pr.getItems().length == 0) return;

        HashMap<String, String> map = (HashMap<String, String>)
            MrzParser.parse(pr.getItems()[0]);
        if (map.isEmpty()) return;

        // ... portrait detection logic ...

        mPendingLabelMap = map;
        runOnUiThread(() -> {
            btnCapture.setEnabled(true);
            tvStatus.setText("Ready");
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

The MrzParser.parse() helper uses the SDK's built-in ParsedResultItem.getParsedFields() to extract structured key-value pairs — no regex needed for the primary path:

public static Map<String, String> parse(com.dynamsoft.dcp.ParsedResultItem item) {
    HashMap<String, String> entry = item.getParsedFields();
    HashMap<String, String> properties = new HashMap<>();

    String number = getFirstNonNull(entry, "passportNumber", "documentNumber", "idNumber");
    String firstName = getFirstNonNull(entry, "secondaryIdentifier", "givenNames");
    String lastName = getFirstNonNull(entry, "primaryIdentifier", "lastName");
    String nationality = entry.get("nationality") != null ? entry.get("nationality") : "Unknown";
    String issuingState = entry.get("issuingState") != null ? entry.get("issuingState") : "Unknown";
    String sex = entry.get("sex") != null ? entry.get("sex") : "Unknown";

    String birthDate = formatDate(
        entry.get("birthYear"), entry.get("birthMonth"), entry.get("birthDay"));
    String expiryDate = formatDate(
        entry.get("expiryYear"), entry.get("expiryMonth"), entry.get("expiryDay"));

    properties.put("Document Type", docType);
    properties.put("Name", fullName);
    properties.put("Sex", formatSex(sex));
    properties.put("Age", age >= 0 ? String.valueOf(age) : "—");
    properties.put("Document Number", number.isEmpty() ? "—" : number);
    properties.put("Issuing State", issuingState);
    properties.put("Nationality", nationality);
    properties.put("Date of Birth(YYYY-MM-DD)", birthDate.isEmpty() ? "—" : birthDate);
    properties.put("Date of Expiry(YYYY-MM-DD)", expiryDate.isEmpty() ? "—" : expiryDate);

    return properties;
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Validate the Portrait Zone and Show the Scan Results

The IdentityProcessor can detect a portrait zone from the intermediate results. Before using it, the code checks for a high-confidence auxiliary region tagged "PortraitZone" and validates the quadrilateral against document bounds:

// Check auxiliary regions for PortraitZone with confidence > 60
int highConfIdx = -1;
if (mLocalizedTextLinesUnit.getAuxiliaryRegionElementsCount() > 0) {
    for (int i = 0; i < mLocalizedTextLinesUnit.getAuxiliaryRegionElementsCount(); i++) {
        if ("PortraitZone".equals(mLocalizedTextLinesUnit.getAuxiliaryRegionElement(i).getName())) {
            if (mLocalizedTextLinesUnit.getAuxiliaryRegionElement(i).getConfidence() > 60) {
                highConfIdx = i;
                break;
            }
        }
    }
}

// Find the portrait zone
Quadrilateral pz = null;
if (highConfIdx != -1 && mDetectedQuadsUnit != null && mDetectedQuadsUnit.getCount() > 0) {
    pz = mIdProcessor.findPortraitZone(
        mScaledColourImageUnit, mLocalizedTextLinesUnit,
        mRecognizedTextLinesUnit, mDetectedQuadsUnit, mDeskewedImageUnit);
}

// Validate portrait is inside document and proportionally reasonable
if (pz != null && quadItem != null) {
    Quadrilateral docRegion = quadItem.getLocation();
    boolean valid = docRegion.isPointInQuadrilateral(pz.points[0])
        && docRegion.isPointInQuadrilateral(pz.points[1])
        && docRegion.isPointInQuadrilateral(pz.points[2])
        && docRegion.isPointInQuadrilateral(pz.points[3])
        && docRegion.getArea() / pz.getArea() >= 3;
    if (!valid) pz = null;
}
Enter fullscreen mode Exit fullscreen mode

Once validated, the portrait region is deskewed (warped to a rectangle) and passed to ScanResultActivity as a JPEG byte array alongside the parsed field map.

MRZ and portrait results

Source Code

https://github.com/yushulx/android-camera-barcode-mrz-document-scanner/tree/main/examples/IdScanner

Top comments (0)