Building the ScanMRZ Demo App
The MRZ Scanner User Guide builds ScanMRZBasic, the smallest app that scans an MRZ and shows the parsed data on a single screen. This page builds ScanMRZ, the complete demo app on top of it.
The MRZ Scanner Demo published on Google Play is this same implementation behind a more polished landing screen. Building
ScanMRZgives you the full scanning and result experience; the store app adds branding around it.
Everything on this page is presentation. It uses the same SDK calls the user guide covers — MRZScannerConfig, MRZScannerActivity.ResultContract, and the getters on MRZScanResult — and adds a result screen around them. None of it is required in order to use the MRZ Scanner.
Full source on GitHub: ScanMRZ (Java) and ScanMRZKt (Kotlin). Open
android/samplesin Android Studio and choose the matching run configuration.
What ScanMRZ adds
| ScanMRZBasic | ScanMRZ | |
|---|---|---|
| Screens | one activity | MainActivity plus a dedicated ResultActivity |
| Result data | seven fields, plain text | full field set, grouped into personal and document sections |
| Images | portrait only | portrait, plus a tabbed pager for the processed and original document images on both sides |
| Failed validation | field turns amber | amber, an error icon, an underline, and a tap-to-explain dialog |
| Camera denial | error string | error string plus an Open Settings action and recovery on resume |
The result screen layouts
activity_results.xml
Create activity_results.xml in src/main/res/layout/. This layout contains the scrollable result area, an error text view for exception states, a header with the portrait and key identity details, a tab-based image pager for document photos, detailed personal and document info fields, the raw MRZ text, and the action buttons:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ResultActivity">
<TextView
android:id="@+id/no_result_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
android:gravity="center"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@+id/ll_buttons"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/result_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/ll_buttons"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="100dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tv_full_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_gender_and_age"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_expiry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="8dp"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/iv_portrait"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1.2"
android:contentDescription="Portrait"
android:src="@drawable/ic_portrait_placeholder" />
</LinearLayout>
<TextView
android:id="@+id/tv_images_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp"
android:textColor="@color/white"
android:textStyle="bold"
android:visibility="gone" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_images"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:tabGravity="center"
app:tabMode="fixed" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp_images"
android:layout_width="match_parent"
android:layout_height="162dp"
android:layout_marginTop="8dp"
android:overScrollMode="never" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp"
android:text="Personal Info"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Given Name" />
<TextView
android:id="@+id/tv_given_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Surname" />
<TextView
android:id="@+id/tv_surname"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Date of Birth" />
<TextView
android:id="@+id/tv_date_of_birth"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Gender" />
<TextView
android:id="@+id/tv_gender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Nationality" />
<TextView
android:id="@+id/tv_nationality"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp"
android:text="Document Info"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Doc. Type" />
<TextView
android:id="@+id/tv_doc_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Doc. Number" />
<TextView
android:id="@+id/tv_doc_number"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Expiry Date" />
<TextView
android:id="@+id/tv_expiry_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="24dp"
android:text="Raw MRZ Text"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_raw_mrz"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:paddingTop="8dp"
android:paddingBottom="16dp" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:id="@+id/ll_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_rescan"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="Re-Scan" />
<!-- Hidden by default. Takes the Re-Scan slot when the scan failed because
camera access is unavailable — see Step 8. -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_open_settings"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:visibility="gone"
android:text="Open Settings" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_return_home"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:text="Return Home" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
ic_portrait_placeholder.xml
Create ic_portrait_placeholder.xml in src/main/res/drawable/. This vector drawable is shown as the portrait fallback when no portrait image was captured:
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88dp"
android:height="88dp"
android:viewportWidth="88"
android:viewportHeight="88">
<path
android:pathData="M79.382,75.625C79.141,76.043 78.794,76.39 78.375,76.632C77.957,76.873 77.483,77 77,77H11C10.517,76.999 10.044,76.872 9.626,76.631C9.208,76.389 8.862,76.042 8.621,75.624C8.38,75.206 8.253,74.732 8.253,74.249C8.253,73.767 8.38,73.293 8.621,72.875C13.857,63.824 21.924,57.334 31.34,54.257C26.682,51.485 23.064,47.26 21.04,42.232C19.016,37.204 18.699,31.651 20.137,26.425C21.574,21.199 24.688,16.59 28.999,13.305C33.31,10.02 38.58,8.241 44,8.241C49.42,8.241 54.69,10.02 59.001,13.305C63.312,16.59 66.426,21.199 67.863,26.425C69.301,31.651 68.984,37.204 66.96,42.232C64.936,47.26 61.318,51.485 56.66,54.257C66.076,57.334 74.143,63.824 79.379,72.875C79.621,73.293 79.748,73.767 79.749,74.249C79.749,74.732 79.623,75.207 79.382,75.625Z"
android:fillColor="#000000"/>
</vector>
colors.xml
Open colors.xml in src/main/res/values/ and add the amber used to flag MRZ fields that fail validation. Both the error icon below and ResultActivity reference it:
<color name="warning_amber">#FFC107</color>
ic_error_circle.xml
Create ic_error_circle.xml in src/main/res/drawable/. This vector drawable is the Material error glyph, displayed next to any field whose check digit fails validation:
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/warning_amber"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2V7h2v6z" />
</vector>
A circle-exclamation is used rather than a warning triangle because a failed check digit is an invalid-data state, not a caution. This matches Material’s convention of reserving the triangle for warnings.
Registering ResultActivity
Open AndroidManifest.xml and declare ResultActivity inside the <application> block:
<activity
android:name=".ResultActivity"
android:exported="false"
android:screenOrientation="portrait" />
exported="false" matters here. MainActivity starts this screen with an explicit Intent, which never requires the activity to be exported — and leaving it exported would let any other app on the device launch it with a scan result of its own choosing.
MRZScannerActivityis already declared in the library manifest with a defaultscreenOrientationofportrait. If you need to override its orientation, redeclare it in your app manifest withtools:replace="android:screenOrientation".
ImagesFragment
ImagesFragment renders one or two document images side by side. The ViewPager2 adapter in ResultActivity uses it to show the cropped and original scan images.
The images reach the fragment through setArguments(Bundle) rather than a constructor. FragmentManager recreates fragments by reflection, so it needs a public no-arg constructor and can only restore state it finds in the arguments Bundle. Because ImageData is not itself serializable into a Bundle, each image is encoded to JPEG bytes on the way in and decoded on the way out. The JPEG quality and maximum dimension are sized against the roughly 1 MB Binder transaction that carries saved instance state: 85% quality and a 1024 px cap keep each image under about 100 KB, comfortably clear of the limit even when both slots are used.
Do not pass
ImageDatathrough the fragment’s constructor. That form compiles, butResultActivitywill crash atsuper.onCreate(...)whenever the activity is recreated — after a configuration change, under Don’t keep activities, or following process death.
- Java
- Kotlin
package com.dynamsoft.scanmrz; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Bundle; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.dynamsoft.core.basic_structures.CoreException; import com.dynamsoft.core.basic_structures.ImageData; import java.io.ByteArrayOutputStream; public class ImagesFragment extends Fragment { private static final String ARG_IMAGE_1 = "image1"; private static final String ARG_IMAGE_2 = "image2"; private static final int JPEG_QUALITY = 85; private static final int MAX_DIMENSION_PX = 1024; public ImagesFragment() { super(); } @NonNull public static ImagesFragment newInstance(@Nullable ImageData imageData1, @Nullable ImageData imageData2) { ImagesFragment fragment = new ImagesFragment(); Bundle args = new Bundle(); byte[] bytes1 = encode(imageData1); byte[] bytes2 = encode(imageData2); if (bytes1 != null) args.putByteArray(ARG_IMAGE_1, bytes1); if (bytes2 != null) args.putByteArray(ARG_IMAGE_2, bytes2); fragment.setArguments(args); return fragment; } @Nullable private static byte[] encode(@Nullable ImageData imageData) { if (imageData == null) return null; try { Bitmap bmp = downscaleIfNeeded(imageData.toBitmap()); ByteArrayOutputStream out = new ByteArrayOutputStream(); bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out); return out.toByteArray(); } catch (CoreException ignored) { return null; } } @NonNull private static Bitmap downscaleIfNeeded(@NonNull Bitmap src) { int w = src.getWidth(); int h = src.getHeight(); int max = Math.max(w, h); if (max <= MAX_DIMENSION_PX) return src; float scale = (float) MAX_DIMENSION_PX / max; return Bitmap.createScaledBitmap(src, Math.round(w * scale), Math.round(h * scale), true); } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { LinearLayout root = new LinearLayout(requireContext()); root.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT )); root.setOrientation(LinearLayout.HORIZONTAL); root.setGravity(Gravity.CENTER_VERTICAL); root.setBaselineAligned(false); root.setClipToPadding(false); root.setClipChildren(false); return root; } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { Bundle args = getArguments(); if (args == null) return; LinearLayout root = (LinearLayout) view; byte[] bytes1 = args.getByteArray(ARG_IMAGE_1); byte[] bytes2 = args.getByteArray(ARG_IMAGE_2); addImageView(root, bytes1); if (bytes1 != null && bytes2 != null) { root.addView(new View(requireContext()), new LinearLayout.LayoutParams( (int) (16 * getResources().getDisplayMetrics().density), ViewGroup.LayoutParams.MATCH_PARENT)); } addImageView(root, bytes2); } private void addImageView(@NonNull LinearLayout root, @Nullable byte[] bytes) { if (bytes == null) return; Bitmap bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); if (bmp == null) return; ImageView iv = new ImageView(requireContext()); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f); iv.setLayoutParams(lp); iv.setScaleType(ImageView.ScaleType.FIT_CENTER); iv.setAdjustViewBounds(true); iv.setImageBitmap(bmp); root.addView(iv); } }package com.dynamsoft.scanmrz import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import androidx.fragment.app.Fragment import com.dynamsoft.core.basic_structures.CoreException import com.dynamsoft.core.basic_structures.ImageData import java.io.ByteArrayOutputStream import kotlin.math.max import kotlin.math.roundToInt class ImagesFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val root = LinearLayout(requireContext()) root.layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) root.orientation = LinearLayout.HORIZONTAL root.gravity = Gravity.CENTER_VERTICAL root.isBaselineAligned = false root.clipToPadding = false root.clipChildren = false return root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val args = arguments ?: return val root = view as LinearLayout val bytes1 = args.getByteArray(ARG_IMAGE_1) val bytes2 = args.getByteArray(ARG_IMAGE_2) addImageView(root, bytes1) if (bytes1 != null && bytes2 != null) { root.addView( View(requireContext()), LinearLayout.LayoutParams( (16 * resources.displayMetrics.density).toInt(), ViewGroup.LayoutParams.MATCH_PARENT ) ) } addImageView(root, bytes2) } private fun addImageView(root: LinearLayout, bytes: ByteArray?) { if (bytes == null) return val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return val iv = ImageView(requireContext()) iv.layoutParams = LinearLayout.LayoutParams( 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f ) iv.scaleType = ImageView.ScaleType.FIT_CENTER iv.adjustViewBounds = true iv.setImageBitmap(bmp) root.addView(iv) } companion object { private const val ARG_IMAGE_1 = "image1" private const val ARG_IMAGE_2 = "image2" private const val JPEG_QUALITY = 85 private const val MAX_DIMENSION_PX = 1024 fun newInstance(imageData1: ImageData?, imageData2: ImageData?): ImagesFragment { val fragment = ImagesFragment() val args = Bundle() encode(imageData1)?.let { args.putByteArray(ARG_IMAGE_1, it) } encode(imageData2)?.let { args.putByteArray(ARG_IMAGE_2, it) } fragment.arguments = args return fragment } private fun encode(imageData: ImageData?): ByteArray? { if (imageData == null) return null return try { val bmp = downscaleIfNeeded(imageData.toBitmap()) val out = ByteArrayOutputStream() bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) out.toByteArray() } catch (ignored: CoreException) { null } } private fun downscaleIfNeeded(src: Bitmap): Bitmap { val w = src.width val h = src.height val maxDimension = max(w, h) if (maxDimension <= MAX_DIMENSION_PX) return src val scale = MAX_DIMENSION_PX.toFloat() / maxDimension return Bitmap.createScaledBitmap(src, (w * scale).roundToInt(), (h * scale).roundToInt(), true) } } }
ResultActivity
ResultActivity receives the MRZScanResult passed from MainActivity, handles all three result statuses, and populates the result screen with the extracted MRZ data, portrait image, and document images.
Per-field validation. The applyField helper renders any field whose getFieldValidationStatus is VS_FAILED in amber with a trailing error icon, underlines it to signal that it is tappable, and opens a short explanation when tapped. The top summary block is deliberately left unstyled: it combines several values on one line, so flagging it on a single field’s status would imply that everything on that line is suspect.
Reading the data. A finished scan normally carries data, but getData() is guarded rather than assumed — a null there shows the same empty-state text as an error instead of crashing. The gender field is title-cased with Locale.ROOT, since it is an ICAO code rather than localized text. Doc. Type is passed VS_NONE because it is derived from the MRZ code type rather than read from a field with its own check digit. The raw MRZ text is tappable for a reason that is easy to miss: a line-level failure can flag it when no individual field failed, which is what happens when the corruption lands in a field that carries no check digit of its own, such as name, nationality or sex.
Camera-permission failures. showCameraPermissionAction replaces Re-Scan with Open Settings for EC_CAMERA_PERMISSION_DENIED, and onResume re-checks the permission so that granting it in Settings and returning starts a new scan instead of leaving a stale error on screen. Open Settings is not offered for EC_CAMERA_PERMISSION_RESTRICTED, because device policy withholds the camera and the per-app toggle is absent from Settings in that state.
The code below reads one string resource, so add it to strings.xml alongside the entries the layouts use:
<string name="scan_no_data">Scan returned no data</string>
The image tabs. Tabs appear only when both sets came back, since a tab bar with one tab is a control with nothing to switch between. returnOriginalImage is off by default, so the usual case is a single set, which gets a plain Processed Image(s) header styled like the sections below it. Page 0 is the processed pair whenever processed images exist, otherwise the single page holds the original pair.
- Java
- Kotlin
package com.dynamsoft.scanmrz; import android.Manifest; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.provider.Settings; import android.text.SpannableString; import android.text.Spanned; import android.text.style.ImageSpan; import android.text.style.UnderlineSpan; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.viewpager2.adapter.FragmentStateAdapter; import androidx.viewpager2.widget.ViewPager2; import com.dynamsoft.core.basic_structures.CoreException; import com.dynamsoft.core.basic_structures.ImageData; import com.dynamsoft.dcp.EnumValidationStatus; import com.dynamsoft.mrzscannerbundle.ui.EnumDocumentSide; import com.dynamsoft.mrzscannerbundle.ui.MRZData; import com.dynamsoft.mrzscannerbundle.ui.MRZScanResult; import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayoutMediator; import java.util.Locale; public class ResultActivity extends AppCompatActivity { public static final int REQUEST_CODE = 1024; public static final String EXTRA_RESULT = "RESULT"; public static final String EXTRA_ACTION = "ACTION"; public static final int ACTION_RESCAN = 0; public static final int ACTION_RETURN_HOME = 1; private boolean isShowingCameraPermissionError = false; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_results); ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); return insets; }); MRZScanResult scanResult = (MRZScanResult) getIntent().getParcelableExtra(EXTRA_RESULT); if (scanResult != null) { showMRZScanResult(scanResult); } findViewById(R.id.btn_rescan).setOnClickListener(v -> { setResult(RESULT_OK, getIntent().putExtra(EXTRA_ACTION, ACTION_RESCAN)); finish(); }); findViewById(R.id.btn_return_home).setOnClickListener(v -> { setResult(RESULT_OK, getIntent().putExtra(EXTRA_ACTION, ACTION_RETURN_HOME)); finish(); }); } @Override protected void onResume() { super.onResume(); if (isShowingCameraPermissionError && hasCameraPermission()) { setResult(RESULT_OK, getIntent().putExtra(EXTRA_ACTION, ACTION_RESCAN)); finish(); } } private boolean hasCameraPermission() { return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED; } private void showMRZScanResult(MRZScanResult result) { if (result.getResultStatus() == MRZScanResult.EnumResultStatus.RS_CANCELED) { setResult(RESULT_OK, getIntent().putExtra(EXTRA_ACTION, ACTION_RETURN_HOME)); finish(); return; } if (result.getResultStatus() == MRZScanResult.EnumResultStatus.RS_EXCEPTION) { findViewById(R.id.result_view).setVisibility(View.GONE); TextView tvNoResult = findViewById(R.id.no_result_view); tvNoResult.setVisibility(View.VISIBLE); tvNoResult.setText(result.getErrorString()); showCameraPermissionAction(result.getErrorCode()); return; } MRZData data = result.getData(); if (data == null) { findViewById(R.id.result_view).setVisibility(View.GONE); TextView tvNoData = findViewById(R.id.no_result_view); tvNoData.setVisibility(View.VISIBLE); tvNoData.setText(R.string.scan_no_data); return; } findViewById(R.id.result_view).setVisibility(View.VISIBLE); findViewById(R.id.no_result_view).setVisibility(View.GONE); String sexText = data.getSex(); String genderText = sexText.isEmpty() ? "" : sexText.substring(0, 1).toUpperCase(Locale.ROOT) + sexText.substring(1).toLowerCase(Locale.ROOT); ((TextView) findViewById(R.id.tv_full_name)).setText((data.getFirstName() + " " + data.getLastName()).trim()); ((TextView) findViewById(R.id.tv_gender_and_age)).setText( genderText.isEmpty() && data.getAge() == 0 ? "" : genderText + ", " + data.getAge() + " years old"); ((TextView) findViewById(R.id.tv_expiry)).setText(data.getDateOfExpire().isEmpty() ? "" : "Expiry: " + data.getDateOfExpire()); ImageView ivPortrait = findViewById(R.id.iv_portrait); ImageData portraitImage = result.getPortraitImage(); if (portraitImage != null) { try { ivPortrait.setImageBitmap(portraitImage.toBitmap()); } catch (CoreException ignored) { } } else { ivPortrait.setImageResource(R.drawable.ic_portrait_placeholder); } showImages(result); applyField(findViewById(R.id.tv_given_name), data.getFirstName(), data.getFieldValidationStatus("firstName")); applyField(findViewById(R.id.tv_surname), data.getLastName(), data.getFieldValidationStatus("lastName")); applyField(findViewById(R.id.tv_date_of_birth), data.getDateOfBirth(), data.getFieldValidationStatus("dateOfBirth")); applyField(findViewById(R.id.tv_gender), genderText, data.getFieldValidationStatus("sex")); applyField(findViewById(R.id.tv_nationality), data.getNationality(), data.getFieldValidationStatus("nationality")); String docTypeText; switch (data.getDocumentType() == null ? "" : data.getDocumentType()) { case "MRTD_TD1_ID": docTypeText = "ID (TD1)"; break; case "MRTD_TD2_ID": docTypeText = "ID (TD2)"; break; case "MRTD_TD3_PASSPORT": docTypeText = "Passport (TD3)"; break; default: docTypeText = ""; break; } applyField(findViewById(R.id.tv_doc_type), docTypeText, EnumValidationStatus.VS_NONE); applyField(findViewById(R.id.tv_doc_number), data.getDocumentNumber(), data.getFieldValidationStatus("documentNumber")); applyField(findViewById(R.id.tv_expiry_date), data.getDateOfExpire(), data.getFieldValidationStatus("dateOfExpire")); applyField(findViewById(R.id.tv_raw_mrz), data.getMrzText(), data.getFieldValidationStatus("mrzText")); } private void applyField(TextView tv, String value, int status) { boolean failed = status == EnumValidationStatus.VS_FAILED; boolean empty = value == null || value.isEmpty(); String text = empty ? "N/A" : value; if (failed) { Drawable icon = ContextCompat.getDrawable(this, R.drawable.ic_error_circle); SpannableString spannable = new SpannableString(icon == null ? text : text + " "); if (icon != null) { int iconSize = Math.round(tv.getTextSize() * 1.2f); icon.setBounds(0, 0, iconSize, iconSize); spannable.setSpan(new ImageSpan(icon, ImageSpan.ALIGN_BOTTOM), spannable.length() - 1, spannable.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } spannable.setSpan(new UnderlineSpan(), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); tv.setText(spannable); } else { tv.setText(text); } tv.setTextColor(ContextCompat.getColor(this, failed ? R.color.warning_amber : R.color.white)); if (failed) { tv.setOnClickListener(v -> showValidationInfoDialog()); } else { tv.setOnClickListener(null); tv.setClickable(false); } } private void showValidationInfoDialog() { new AlertDialog.Builder(this) .setTitle("Field validation warning") .setMessage("This value doesn't match its check digit. The document may be invalid or altered.") .setPositiveButton("OK", null) .show(); } private void showCameraPermissionAction(int errorCode) { if (errorCode != MRZScanResult.EnumErrorCode.EC_CAMERA_PERMISSION_DENIED) { return; } isShowingCameraPermissionError = true; findViewById(R.id.btn_rescan).setVisibility(View.GONE); View btnOpenSettings = findViewById(R.id.btn_open_settings); btnOpenSettings.setVisibility(View.VISIBLE); btnOpenSettings.setOnClickListener(v -> startActivity( new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", getPackageName(), null)))); } private void showImages(MRZScanResult result) { ImageData mrzSideDocumentImage = result.getDocumentImage(EnumDocumentSide.DS_MRZ); ImageData oppositeSideDocumentImage = result.getDocumentImage(EnumDocumentSide.DS_OPPOSITE); ImageData mrzSideOriginalImage = result.getOriginalImage(EnumDocumentSide.DS_MRZ); ImageData oppositeSideOriginalImage = result.getOriginalImage(EnumDocumentSide.DS_OPPOSITE); TabLayout tabImages = findViewById(R.id.tab_images); ViewPager2 vpImages = findViewById(R.id.vp_images); TextView tvImagesHeader = findViewById(R.id.tv_images_header); boolean hasProcessed = mrzSideDocumentImage != null || oppositeSideDocumentImage != null; boolean hasOriginal = mrzSideOriginalImage != null || oppositeSideOriginalImage != null; if (!hasProcessed && !hasOriginal) { tvImagesHeader.setVisibility(View.GONE); tabImages.setVisibility(View.GONE); vpImages.setVisibility(View.GONE); return; } boolean showsTabs = hasProcessed && hasOriginal; tabImages.setVisibility(showsTabs ? View.VISIBLE : View.GONE); tvImagesHeader.setVisibility(showsTabs ? View.GONE : View.VISIBLE); tvImagesHeader.setText(hasProcessed ? "Processed Image(s)" : "Original Image(s)"); vpImages.setVisibility(View.VISIBLE); vpImages.setAdapter(new FragmentStateAdapter(this) { @NonNull @Override public Fragment createFragment(int position) { if (position == 0 && hasProcessed) { return ImagesFragment.newInstance(mrzSideDocumentImage, oppositeSideDocumentImage); } else { return ImagesFragment.newInstance(mrzSideOriginalImage, oppositeSideOriginalImage); } } @Override public int getItemCount() { return hasProcessed && hasOriginal ? 2 : 1; } }); if (showsTabs) { new TabLayoutMediator(tabImages, vpImages, (tab, position) -> tab.setText(position == 0 ? "Processed" : "Original")).attach(); } } }package com.dynamsoft.scanmrz import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.provider.Settings import android.text.SpannableString import android.text.Spanned import android.text.style.ImageSpan import android.text.style.UnderlineSpan import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.dynamsoft.core.basic_structures.CoreException import com.dynamsoft.dcp.EnumValidationStatus import com.dynamsoft.mrzscannerbundle.ui.EnumDocumentSide import com.dynamsoft.mrzscannerbundle.ui.MRZScanResult import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import java.util.Locale import kotlin.math.roundToInt class ResultActivity : AppCompatActivity() { private var isShowingCameraPermissionError = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_results) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } @Suppress("DEPRECATION") val scanResult = intent.getParcelableExtra<MRZScanResult>(EXTRA_RESULT) if (scanResult != null) { showMRZScanResult(scanResult) } findViewById<View>(R.id.btn_rescan).setOnClickListener { setResult(RESULT_OK, intent.putExtra(EXTRA_ACTION, ACTION_RESCAN)) finish() } findViewById<View>(R.id.btn_return_home).setOnClickListener { setResult(RESULT_OK, intent.putExtra(EXTRA_ACTION, ACTION_RETURN_HOME)) finish() } } override fun onResume() { super.onResume() if (isShowingCameraPermissionError && hasCameraPermission()) { setResult(RESULT_OK, intent.putExtra(EXTRA_ACTION, ACTION_RESCAN)) finish() } } private fun hasCameraPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED private fun showMRZScanResult(result: MRZScanResult) { if (result.resultStatus == MRZScanResult.EnumResultStatus.RS_CANCELED) { setResult(RESULT_OK, intent.putExtra(EXTRA_ACTION, ACTION_RETURN_HOME)) finish() return } if (result.resultStatus == MRZScanResult.EnumResultStatus.RS_EXCEPTION) { findViewById<View>(R.id.result_view).visibility = View.GONE val tvNoResult = findViewById<TextView>(R.id.no_result_view) tvNoResult.visibility = View.VISIBLE tvNoResult.text = result.errorString showCameraPermissionAction(result.errorCode) return } val data = result.data if (data == null) { findViewById<View>(R.id.result_view).visibility = View.GONE val tvNoData = findViewById<TextView>(R.id.no_result_view) tvNoData.visibility = View.VISIBLE tvNoData.setText(R.string.scan_no_data) return } findViewById<View>(R.id.result_view).visibility = View.VISIBLE findViewById<View>(R.id.no_result_view).visibility = View.GONE val sexText = data.sex val genderText = if (sexText.isEmpty()) "" else sexText.substring(0, 1).uppercase(Locale.ROOT) + sexText.substring(1).lowercase(Locale.ROOT) findViewById<TextView>(R.id.tv_full_name).text = (data.firstName + " " + data.lastName).trim() findViewById<TextView>(R.id.tv_gender_and_age).text = if (genderText.isEmpty() && data.age == 0) "" else "$genderText, ${data.age} years old" findViewById<TextView>(R.id.tv_expiry).text = if (data.dateOfExpire.isEmpty()) "" else "Expiry: ${data.dateOfExpire}" val ivPortrait = findViewById<ImageView>(R.id.iv_portrait) val portraitImage = result.portraitImage if (portraitImage != null) { try { ivPortrait.setImageBitmap(portraitImage.toBitmap()) } catch (ignored: CoreException) { } } else { ivPortrait.setImageResource(R.drawable.ic_portrait_placeholder) } showImages(result) applyField(findViewById(R.id.tv_given_name), data.firstName, data.getFieldValidationStatus("firstName")) applyField(findViewById(R.id.tv_surname), data.lastName, data.getFieldValidationStatus("lastName")) applyField(findViewById(R.id.tv_date_of_birth), data.dateOfBirth, data.getFieldValidationStatus("dateOfBirth")) applyField(findViewById(R.id.tv_gender), genderText, data.getFieldValidationStatus("sex")) applyField(findViewById(R.id.tv_nationality), data.nationality, data.getFieldValidationStatus("nationality")) val docTypeText = when (data.documentType ?: "") { "MRTD_TD1_ID" -> "ID (TD1)" "MRTD_TD2_ID" -> "ID (TD2)" "MRTD_TD3_PASSPORT" -> "Passport (TD3)" else -> "" } applyField(findViewById(R.id.tv_doc_type), docTypeText, EnumValidationStatus.VS_NONE) applyField(findViewById(R.id.tv_doc_number), data.documentNumber, data.getFieldValidationStatus("documentNumber")) applyField(findViewById(R.id.tv_expiry_date), data.dateOfExpire, data.getFieldValidationStatus("dateOfExpire")) applyField(findViewById(R.id.tv_raw_mrz), data.mrzText, data.getFieldValidationStatus("mrzText")) } private fun applyField(tv: TextView, value: String?, status: Int) { val failed = status == EnumValidationStatus.VS_FAILED val text = if (value.isNullOrEmpty()) "N/A" else value if (failed) { val icon = ContextCompat.getDrawable(this, R.drawable.ic_error_circle) val spannable = SpannableString(if (icon == null) text else "$text ") if (icon != null) { val iconSize = (tv.textSize * 1.2f).roundToInt() icon.setBounds(0, 0, iconSize, iconSize) spannable.setSpan( ImageSpan(icon, ImageSpan.ALIGN_BOTTOM), spannable.length - 1, spannable.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) } spannable.setSpan(UnderlineSpan(), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) tv.text = spannable } else { tv.text = text } tv.setTextColor( ContextCompat.getColor(this, if (failed) R.color.warning_amber else R.color.white) ) if (failed) { tv.setOnClickListener { showValidationInfoDialog() } } else { tv.setOnClickListener(null) tv.isClickable = false } } private fun showValidationInfoDialog() { AlertDialog.Builder(this) .setTitle("Field validation warning") .setMessage("This value doesn't match its check digit. The document may be invalid or altered.") .setPositiveButton("OK", null) .show() } private fun showCameraPermissionAction(errorCode: Int) { if (errorCode != MRZScanResult.EnumErrorCode.EC_CAMERA_PERMISSION_DENIED) { return } isShowingCameraPermissionError = true findViewById<View>(R.id.btn_rescan).visibility = View.GONE val btnOpenSettings = findViewById<View>(R.id.btn_open_settings) btnOpenSettings.visibility = View.VISIBLE btnOpenSettings.setOnClickListener { startActivity( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null) ) ) } } private fun showImages(result: MRZScanResult) { val mrzSideDocumentImage = result.getDocumentImage(EnumDocumentSide.DS_MRZ) val oppositeSideDocumentImage = result.getDocumentImage(EnumDocumentSide.DS_OPPOSITE) val mrzSideOriginalImage = result.getOriginalImage(EnumDocumentSide.DS_MRZ) val oppositeSideOriginalImage = result.getOriginalImage(EnumDocumentSide.DS_OPPOSITE) val tabImages = findViewById<TabLayout>(R.id.tab_images) val vpImages = findViewById<ViewPager2>(R.id.vp_images) val tvImagesHeader = findViewById<TextView>(R.id.tv_images_header) val hasProcessed = mrzSideDocumentImage != null || oppositeSideDocumentImage != null val hasOriginal = mrzSideOriginalImage != null || oppositeSideOriginalImage != null if (!hasProcessed && !hasOriginal) { tvImagesHeader.visibility = View.GONE tabImages.visibility = View.GONE vpImages.visibility = View.GONE return } val showsTabs = hasProcessed && hasOriginal tabImages.visibility = if (showsTabs) View.VISIBLE else View.GONE tvImagesHeader.visibility = if (showsTabs) View.GONE else View.VISIBLE tvImagesHeader.text = if (hasProcessed) "Processed Image(s)" else "Original Image(s)" vpImages.visibility = View.VISIBLE vpImages.adapter = object : FragmentStateAdapter(this) { override fun createFragment(position: Int): Fragment { return if (position == 0 && hasProcessed) { ImagesFragment.newInstance(mrzSideDocumentImage, oppositeSideDocumentImage) } else { ImagesFragment.newInstance(mrzSideOriginalImage, oppositeSideOriginalImage) } } override fun getItemCount(): Int { return if (hasProcessed && hasOriginal) 2 else 1 } } if (showsTabs) { TabLayoutMediator(tabImages, vpImages) { tab, position -> tab.text = if (position == 0) "Processed" else "Original" }.attach() } } companion object { const val REQUEST_CODE = 1024 const val EXTRA_RESULT = "RESULT" const val EXTRA_ACTION = "ACTION" const val ACTION_RESCAN = 0 const val ACTION_RETURN_HOME = 1 } }
MainActivity
The user guide’s MainActivity renders the result on its own screen. Here it does one thing differently: it hands the result to ResultActivity and waits to hear what the user chose.
Two changes make that work. The launcher callback packs the MRZScanResult into an Intent and starts ResultActivity, and onActivityResult reads the action that comes back — relaunching the scanner when the user tapped Re-Scan. onActivityResult is deprecated in favour of the Activity Result APIs; the sample keeps it so the hand-off mirrors the Java version one-for-one.
The license below is a trial key, which needs a network connection. Request your own through Request a Trial License.
- Java
- Kotlin
package com.dynamsoft.scanmrz; import android.content.Intent; import android.os.Bundle; import androidx.activity.EdgeToEdge; import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.dynamsoft.mrzscannerbundle.ui.MRZScannerActivity; import com.dynamsoft.mrzscannerbundle.ui.MRZScannerConfig; public class MainActivity extends AppCompatActivity { private ActivityResultLauncher<MRZScannerConfig> launcher; private final MRZScannerConfig config = new MRZScannerConfig(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); EdgeToEdge.enable(this); setContentView(R.layout.activity_main); ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); return insets; }); config.setLicense("DLS2eyJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSJ9"); launcher = registerForActivityResult(new MRZScannerActivity.ResultContract(), result -> { Intent intent = new Intent(this, ResultActivity.class); intent.putExtra(ResultActivity.EXTRA_RESULT, result); startActivityForResult(intent, ResultActivity.REQUEST_CODE); }); findViewById(R.id.btn_start).setOnClickListener(v -> launcher.launch(config)); } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == ResultActivity.REQUEST_CODE && resultCode == RESULT_OK) { int action = data.getIntExtra(ResultActivity.EXTRA_ACTION, ResultActivity.ACTION_RETURN_HOME); if (action == ResultActivity.ACTION_RESCAN) { launcher.launch(config); } } } }package com.dynamsoft.scanmrz import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.dynamsoft.mrzscannerbundle.ui.MRZScannerActivity import com.dynamsoft.mrzscannerbundle.ui.MRZScannerConfig class MainActivity : AppCompatActivity() { private lateinit var launcher: ActivityResultLauncher<MRZScannerConfig> private val config = MRZScannerConfig() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContentView(R.layout.activity_main) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } config.license = "DLS2eyJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSJ9" launcher = registerForActivityResult(MRZScannerActivity.ResultContract()) { result -> val intent = Intent(this, ResultActivity::class.java) intent.putExtra(ResultActivity.EXTRA_RESULT, result) startActivityForResult(intent, ResultActivity.REQUEST_CODE) } findViewById<View>(R.id.btn_start).setOnClickListener { launcher.launch(config) } } @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @Suppress("DEPRECATION") super.onActivityResult(requestCode, resultCode, data) if (requestCode == ResultActivity.REQUEST_CODE && resultCode == RESULT_OK) { val action = data?.getIntExtra(ResultActivity.EXTRA_ACTION, ResultActivity.ACTION_RETURN_HOME) ?: ResultActivity.ACTION_RETURN_HOME if (action == ResultActivity.ACTION_RESCAN) { launcher.launch(config) } } } }
The
MRZScanResulttravels through theIntentwith no extra handling. Its images are reference counted, and the instanceResultActivityunparcels takes its own reference. See Results and Image Lifetime.
Next steps
- MRZ Scanner User Guide — build the minimal integration from scratch.
- Demo and Samples — all four Android samples, and the demo app.
- Customizing the MRZ Scanner — document type, UI elements, feedback, and camera permission.
- Android API Reference — all classes and methods.