Introduction
In this article, we will learn how to build a QR code scanner app using ML Kit and the new Camera2 APIs. Of course, to decode a bitmap, we use the ZXing library just to make sure that the scope of this article remains simple for users to understand.
This article uses Jetpack Compose for UI, along with Kotlin. Only an image utility class is written in Java to manipulate bitmaps.
Let's understand the process one by one.
Step 1. Build.gradle
We need to update our gradle file first, though we need ML kit, Camera2 dependencies, and of course Zxing(very popular among Android devs.) We can use Zxing alone, but ML Kit is simple to implement and very fast. Don't forget it is Kotlin-friendly.
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.barcode.scanning)
implementation(libs.zxing.android.embedded)
Or
//Camera view creation and scanning
implementation "androidx.camera:camera-camera2:1.4.1"
implementation "androidx.camera:camera-lifecycle:1.4.1"
implementation "androidx.camera:camera-view:1.4.1"
implementation "com.google.mlkit:barcode-scanning:17.3.0"
Step 2. Prepare a UI
Prepare a UI to be clicked on or where the result is to be shown.
package com.example.myapplication
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.example.myapplication.ui.theme.MyApplicationTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MyApplicationTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding),
this
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier,context: Context) {
var isButtonClick by remember {
mutableStateOf(false)
}
var result by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "QR code detection system", modifier = Modifier.padding(16.dp), fontSize = 30.sp)
Spacer(modifier = Modifier.height(40.dp))
Text(
text = "Result = "+result.ifBlank { "Click the button to start scanning" },
modifier = Modifier.padding(16.dp)
)
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = {
isButtonClick = true
},
modifier = modifier.fillMaxWidth().padding(20.dp)
) {
Text(text = "Start Scan", modifier = Modifier.padding(16.dp))
}
}
if(isButtonClick){
StartScan(onScanOpened = {
isButtonClick = false
if (it.isNotBlank()) {
result = it
}
})
}
}
@Composable
fun StartScan(onScanOpened: (String) -> Unit = {}) {
val context = LocalContext.current
var isPermissionGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
var isLaunching by remember { mutableStateOf(false) }
val scanLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val scannedData = result.data?.getStringExtra("SCAN_RESULT") ?: ""
onScanOpened(scannedData)
} else {
onScanOpened("")
}
isLaunching = false
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
isPermissionGranted = granted
if (granted && !isLaunching) {
isLaunching = true
launchScanActivity(context, scanLauncher)
}
}
LaunchedEffect(Unit) {
// Request permission only if not granted yet
if (!isPermissionGranted) {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
// Launch scan activity once permission is granted
if (isPermissionGranted && !isLaunching) {
isLaunching = true
launchScanActivity(context, scanLauncher)
}
}
}
private fun launchScanActivity(
context: Context,
scanLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
) {
val intent = Intent(context, ScanActivity::class.java)
scanLauncher.launch(intent)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MyApplicationTheme {
}
}
Here, you can see there is a composable function named "Greeting", it consists of a heading, a result, and a button on which we will click and start scanning the QR code.
Step 3. Prepare the camera permission model
Since we are using camera2 APIs, which is the latest one, in order to use these camera APIs, we need the user's permission to access the camera, which will be further used for scanning or capturing an image, particularly a QR image.
StartScan() is a composable function that will take care of permission for you. Refer to MainActivity.kt for this function or utility. I am not again pasting code for this as the function is written above the code provided.
Step 4. Prepare a scanning screen
Now we need a screen with a camera on it, so that we will prepare with camera2 APIs after permission is successfully given by the user. To beautify the screen, we will also create a cutout rectangle in the center of the screen so the user can properly align the QR image inside the rectangle box.
package com.example.myapplication
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.WindowCompat
class ScanActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ScanScreen(
onDismiss = { scannedResult ->
// Return the scanned result and finish the activity
val intent = Intent().apply {
putExtra("SCAN_RESULT", scannedResult)
}
setResult(Activity.RESULT_OK, intent)
finish()
},
onNavigationUp = { finish() }
)
}
hideSystemBars()
}
private fun hideSystemBars() {
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
}
}
@Composable
fun FullScreenEffect() {
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
onDispose { }
}
}
// Very important function it enables scanning
@Composable
fun ScanScreen(
onDismiss: (String?) -> Unit = {},
onNavigationUp: () -> Unit = {}
) {
FullScreenEffect()
Box(modifier = Modifier.fillMaxSize()) {
ScanUtils(
analyzerType = AnalyzerType.BARCODE,
onDismiss = onDismiss,
onNavigationUp = onNavigationUp
)
}
}
This screen will render a camera and some images over it, along with a cut-out rectangle. This activity simply launches the camera thing and processes the result, and gets back to MainActivity.kt
Step 5. Understanding of ScanUtils
ScanUtils is a composable function that does the gallery picking work and analyzes the QR code. There is an Android View in a Box that does preview camera and capture result and then result get back to analyzer BarCodeAnalyzer is another class i have created this class for gallery image processing to extract image data.
Step 6. Understanding of the BarCodeAnalyzer class
In this class, I have initialized the ML Kit options. This is capable of extracting the barcode, QR code text from paper, etc. By using ImageProxy, we have analyzed the image and posted the result back using the callback.
private val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
private val scanner = BarcodeScanning.getClient(options)
BarCodeAnalyzer.kt
package com.example.myapplication
import android.annotation.SuppressLint
import android.content.Context
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
class BarcodeAnalyzer(private val context: Context, private val callback : Callback) : ImageAnalysis.Analyzer {
private val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
private val scanner = BarcodeScanning.getClient(options)
var address = ""
private var listener = callback
@SuppressLint("UnsafeOptInUsageError")
override fun analyze(imageProxy: ImageProxy) {
imageProxy.image
?.let { image ->
scanner.process(
InputImage.fromMediaImage(
image, imageProxy.imageInfo.rotationDegrees
)
).addOnSuccessListener { barcode ->
barcode?.takeIf { it.isNotEmpty() }
?.mapNotNull { it.rawValue }
?.joinToString(",")
?.let {
address = it
listener.onProcessedResult(it)
}
}.addOnCompleteListener {
imageProxy.close()
}
}
}
interface Callback{
fun onProcessedResult(value : String)
}
}
Step 7. Another feature, process image from gallery (read QR from gallery as well)
Picking an image from the Gallery using an intent, only one picker image at a time.
singlePhotoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
From this launcher result, we have a LaunchedEffect to decode an image from the gallery (using Zxing).
LaunchedEffect(key1 = imageRecieved) {
scope.launch {
if (imageUri != null && imageRecieved) {
var bitmap = ImageUtility.manipulateExifData(context, imageUri)
decodeQRCode(bitmap)?.let {
onDismiss(it)
}
imageRecieved = false
}
}
}
![QR code Scanner]()
Conclusion
I am happy to share the code for this article/app - a good utility for a scanner app. Even the flashlight on/off feature is also working, there you can check the result. Hope these code snippets help you to understand the working.