How to Build a QR Scanner App in Jetpack Compose (2025 Step-by-Step Guide for Android Developers)
👋 Introduction
QR codes have become part of everyday life — scan to pay, scan to access menus, Wi-Fi passwords, event tickets, you name it. If you're an Android developer looking to practice Jetpack Compose and modern libraries, building a QR Scanner app is a fantastic project idea.
In this tutorial, we’ll go beyond just showing you code — we’ll explain what’s happening, why we use CameraX, how ML Kit decodes barcodes, and how to blend it smoothly with Jetpack Compose. No XML. No Fragments. Just clean, declarative UI.
By the end, you’ll have a working QR Scanner app and the confidence to build on top of it.
🧠 Why This Tech Stack Works Best: Jetpack Compose, CameraX & ML Kit Together
Let’s briefly understand why we’re using this tech stack:
- Jetpack Compose is Android’s modern UI toolkit — fast to write, fun to use, and no more XML clutter.
- CameraX is Google’s recommended camera library. It simplifies working with device cameras across many models and versions.
- ML Kit (Barcode Scanning) gives us free access to powerful Google-powered machine learning tools — no need to train your own models.
This combination ensures that your app will be modern, efficient, and compatible across a wide range of devices.
How to build AI Chat Bot App in Jetpack Compose?
🛠️ Tools & Dependencies Required
Before you begin coding, make sure your project is ready. Here's what you need:
1. ✅ Android Studio Requirements:
- Android Studio Hedgehog (2023.1.1) or later
- Compile SDK: 34 or above
- Kotlin version: 1.9.0 or later
2. 📦 Gradle Dependencies
Open your build.gradle (app) file and add the following:
// CameraX
implementation ("androidx.camera:camera-core:1.4.2")
implementation ("androidx.camera:camera-camera2:1.4.2")
implementation ("androidx.camera:camera-lifecycle:1.4.2")
implementation ("androidx.camera:camera-view:1.4.2")
// ML Kit for barcode scanning
implementation ("com.google.mlkit:barcode-scanning:17.3.0")
3. Don’t forget to add required INTERNET permissions in your AndroidManifest.xml:<uses-permission android:name="android.permission.CAMERA" />
4. MainActivity
package com.example.scanneranalyzer
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.example.scanneranalyzer.ui.theme.ScannerAnalyzerTheme
class MainActivity : ComponentActivity() {
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
hasCameraPermission = granted
}
private var hasCameraPermission by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
enableEdgeToEdge()
setContent {
ScannerAnalyzerTheme {
/*Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}*/
if (hasCameraPermission) {
QRScannerScreen()
} else {
Text("Camera permission is required")
}
}
}
}
}
5. 📷Show the Camera Feed with CameraX
Jetpack Compose doesn’t have a direct camera view, so we embed CameraX’s PreviewView using AndroidView.
@Composable
fun CameraPreview(
context: Context,
lifecycleOwner: LifecycleOwner,
modifier: Modifier = Modifier,
onQrCodeScanned: (String) -> Unit
) {
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val previewView = remember { PreviewView(context) }
// Compose wrapper to show Camera Preview
AndroidView(
modifier = modifier,
factory = { previewView }
)
// Camera setup logic in Composable scope
LaunchedEffect(true) {
try {
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().apply {
surfaceProvider = previewView.surfaceProvider
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.apply {
setAnalyzer(
ContextCompat.getMainExecutor(context),
QRScannerAnalyzer(onQrCodeScanned)
)
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalysis
)
} catch (e: Exception) {
Log.e("CameraPreview", "Camera binding failed", e)
}
}
}
6. 🔍 Detect QR Codes with MLKit Barcode Scanner
ML Kit supports multiple barcode formats — we’ll use it to decode QR codes in real-time.
class QRScannerAnalyzer(
private val onQrScanned: (String) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient()
private var lastScanned = ""
@OptIn(ExperimentalGetImage::class)
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: return
if (imageProxy.format != ImageFormat.YUV_420_888) {
imageProxy.close()
return
}
val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcode.rawValue?.let {
if (it != lastScanned) {
lastScanned = it
onQrScanned(it)
}
}
}
}
.addOnFailureListener { Log.e("QRScanner", "Scanning error", it) }
.addOnCompleteListener { imageProxy.close() }
}
}
7: 🖼️Put Everything Together – The UI
Let’s show the preview and scanned result on the screen:
@Composable
fun QRScannerScreen() {
val context = LocalContext.current
var lastScannedTime by remember { mutableStateOf(0L) }
var scannedText by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().systemBarsPadding()) {
Text("Coding Bihar QR Scanner", style = MaterialTheme.typography.headlineMedium)
Box(modifier = Modifier.fillMaxSize()) {
CameraPreview(
context = context,
lifecycleOwner = LocalLifecycleOwner.current,
modifier = Modifier.fillMaxSize(),
onQrCodeScanned = { qr ->
val currentTime = System.currentTimeMillis()
// Allow action if it's a new QR OR if last scan was >2s ago
if (qr != scannedText || (currentTime - lastScannedTime) > 2000) {
scannedText = qr
lastScannedTime = currentTime
Toast.makeText(context, "Scanned: $qr", Toast.LENGTH_SHORT).show()
when {
qr.startsWith("http://") || qr.startsWith("https://") -> {
val intent = Intent(Intent.ACTION_VIEW, qr.toUri())
context.startActivity(intent)
}
qr.startsWith("mailto:") -> {
val intent = Intent(Intent.ACTION_SENDTO, qr.toUri())
context.startActivity(intent)
}
qr.matches(Regex("^\\+?[0-9]{7,15}$")) -> {
val intent = Intent(Intent.ACTION_DIAL, "tel:$qr".toUri())
context.startActivity(intent)
}
qr.startsWith("smsto:") || qr.startsWith("sms:") -> {
val intent = Intent(Intent.ACTION_SENDTO, qr.toUri())
context.startActivity(intent)
}
else -> {
Toast.makeText(context, "Text: $qr", Toast.LENGTH_LONG).show()
}
}
}
}
)
if (scannedText.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Scanned: $scannedText",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}