Project 17: QR Code Scanner with CameraX and ML Kit

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 14 min read · Updated

Frame to result

  1. CameraX shows the preview and gives your analyzer each frame.
  2. The analyzer wraps the frame as an InputImage.
  3. ML Kit looks for barcodes on the device, with no network.
  4. The text is classified, and the screen shows a card. Links need a tap to open.

Scanning a QR code needs three things: a live camera preview, a way to look at each camera frame, and a model that finds codes in it. Android gives you each piece. CameraX is a friendly camera library that follows the lifecycle for you. ML Kit is Google's on-device machine learning toolkit, and its barcode scanner runs on the phone with no network.

This project also teaches an important habit: treat scanned content as untrusted input. A QR code can contain any text, including a link to a harmful website. The app therefore shows the content and asks before it opens a link, and it classifies the result so the screen can offer the right action.

Concepts in this project

  • Camera permission: asked at run time with the Activity Result API. The screen explains why before asking.
  • CameraX use cases: Preview shows the camera, ImageAnalysis gives you frames. bindToLifecycle starts and stops them with the screen.
  • Analyzer: runs on a background thread for each frame. It must close the ImageProxy when done, or the camera stops delivering frames.
  • ML Kit BarcodeScanning: takes an InputImage and returns the barcodes found, with their raw text.
  • AndroidView: shows a classic PreviewView inside Compose.
  • Classifying results: a sealed class (Link, Wifi, Text) and a pure classify function that you can unit test.

Create the project

  1. New Empty Activity project Qr, package com.example.qr.
  2. Add the CameraX and ML Kit libraries from the Project 17 block and sync.
  3. Add <uses-permission android:name="android.permission.CAMERA"/> to the manifest.
  4. Create the files below and test on a real phone with a QR code on a screen or a printout.

`Gradle dependencies for projects 11 to 21 (use the block for this project)`

// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")

// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.

// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.

// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.

// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
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")

// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Qr/app/src/main/java/com/example/qr/
  QrAnalyzer.kt     ML Kit analyzer + classify()
  ScannerScreen.kt  permission, preview, result card
  MainActivity.kt
app/src/test/.../QrTest.kt

The code

`app/src/main/java/com/example/qr/QrAnalyzer.kt`

package com.example.qr

import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage

// Looks at each camera frame and reports the first QR code or barcode it finds.
class QrAnalyzer(private val onResult: (String) -> Unit) : ImageAnalysis.Analyzer {
    private val scanner = BarcodeScanning.getClient()

    @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
    override fun analyze(imageProxy: ImageProxy) {
        val media = imageProxy.image
        if (media == null) {
            imageProxy.close()
            return
        }
        val image = InputImage.fromMediaImage(media, imageProxy.imageInfo.rotationDegrees)
        scanner.process(image)
            .addOnSuccessListener { codes ->
                codes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue?.let(onResult)
            }
            .addOnCompleteListener { imageProxy.close() }   // always release the frame
    }
}

// Decides what kind of content a scanned text is, so the app can offer a sensible action.
sealed interface ScanKind {
    data class Link(val url: String) : ScanKind
    data class Wifi(val ssid: String) : ScanKind
    data class Text(val value: String) : ScanKind
}

fun classify(raw: String): ScanKind {
    val t = raw.trim()
    return when {
        t.startsWith("https://") || t.startsWith("http://") -> ScanKind.Link(t)
        t.startsWith("WIFI:") -> {
            val ssid = t.removePrefix("WIFI:").split(";").firstOrNull { it.startsWith("S:") }?.removePrefix("S:").orEmpty()
            ScanKind.Wifi(ssid)
        }
        else -> ScanKind.Text(t)
    }
}

`app/src/main/java/com/example/qr/ScannerScreen.kt`

package com.example.qr

import android.Manifest
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors

@Composable
fun ScannerScreen() {
    val context = LocalContext.current
    var granted by remember {
        mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == android.content.pm.PackageManager.PERMISSION_GRANTED)
    }
    var result by remember { mutableStateOf<ScanKind?>(null) }
    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it }

    if (!granted) {
        Column(Modifier.fillMaxSize().padding(24.dp)) {
            Text("The camera is needed to read QR codes. Nothing is recorded or stored.")
            Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { Text("Allow camera") }
        }
        return
    }

    Column(Modifier.fillMaxSize()) {
        if (result == null) {
            CameraPreview(Modifier.weight(1f).fillMaxWidth()) { text ->
                if (result == null) result = classify(text)
            }
            Text("Point the camera at a QR code", Modifier.padding(16.dp))
        } else {
            ResultCard(result!!, onAgain = { result = null })
        }
    }
}

@Composable
private fun CameraPreview(modifier: Modifier, onCode: (String) -> Unit) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val executor = remember { Executors.newSingleThreadExecutor() }

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            val view = PreviewView(ctx)
            val providerFuture = ProcessCameraProvider.getInstance(ctx)
            providerFuture.addListener({
                val provider = providerFuture.get()
                val preview = Preview.Builder().build().also { it.setSurfaceProvider(view.surfaceProvider) }
                val analysis = ImageAnalysis.Builder()
                    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                    .build()
                    .also { it.setAnalyzer(executor, QrAnalyzer(onCode)) }
                provider.unbindAll()
                provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
            }, ContextCompat.getMainExecutor(context))
            view
        },
    )
}

@Composable
private fun ResultCard(kind: ScanKind, onAgain: () -> Unit) {
    val context = LocalContext.current
    Card(Modifier.fillMaxWidth().padding(16.dp)) {
        Column(Modifier.padding(16.dp)) {
            when (kind) {
                is ScanKind.Link -> {
                    Text("Link", style = MaterialTheme.typography.titleMedium)
                    Text(kind.url)
                    Text("Only open links you trust.", style = MaterialTheme.typography.bodySmall)
                    Button(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(kind.url))) }) { Text("Open") }
                }
                is ScanKind.Wifi -> {
                    Text("Wi-Fi network", style = MaterialTheme.typography.titleMedium)
                    Text(kind.ssid)
                }
                is ScanKind.Text -> {
                    Text("Text", style = MaterialTheme.typography.titleMedium)
                    Text(kind.value)
                }
            }
            Row(Modifier.padding(top = 12.dp)) {
                OutlinedButton(onClick = onAgain) { Text("Scan again") }
            }
        }
    }
}

`app/src/main/java/com/example/qr/MainActivity.kt`

package com.example.qr

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                ScannerScreen()
            }
        }
    }
}

`app/src/test/java/com/example/qr/QrTest.kt`

package com.example.qr

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class QrTest {
    @Test
    fun linksAreRecognised() {
        val k = classify("https://example.com/a")
        assertTrue(k is ScanKind.Link)
    }

    @Test
    fun wifiNameIsRead() {
        val k = classify("WIFI:T:WPA;S:HomeNet;P:secret;;") as ScanKind.Wifi
        assertEquals("HomeNet", k.ssid)
    }

    @Test
    fun otherTextIsText() {
        assertEquals(ScanKind.Text("hello"), classify("  hello "))
    }
}

How it works

  • If the camera permission is missing, the screen explains and shows an Allow camera button. If it is given, the preview starts.
  • ImageAnalysis uses STRATEGY_KEEP_ONLY_LATEST, so a slow frame never builds a queue.
  • The analyzer passes each frame to ML Kit. When a code is found, the callback gives the text to the screen.
  • classify turns the text into a link, Wi-Fi details or plain text. The result card shows it and, for links, an Open button with a warning.

Expected output

On first launch the screen asks you to allow the camera. After you allow it, you see the live camera and the line "Point the camera at a QR code". Hold a QR code that contains a web address in front of the camera. Within a moment the preview is replaced by a card titled Link, showing the address, a reminder to open only links you trust, an Open button and a Scan again button. A QR code with plain text shows a Text card. Scan again returns to the camera. If you deny the permission, nothing is recorded and the Allow camera button remains.

Your task

  1. Build the app and scan a link, plain text and a Wi-Fi code.
  2. Add a flashlight button using camera.cameraControl.enableTorch.
  3. Add a history list in Room with the scanned values and dates.
  4. Run the three unit tests for classify.

How to check it worked

  • Codes are read within a second or two in good light.
  • Closing the screen stops the camera.
  • The app never opens a link without a tap.

Frequently asked questions

Why must I close the ImageProxy?

CameraX only delivers the next frame after you release the current one. If you forget, the analysis freezes after a few frames.

Does ML Kit send my camera images to a server?

The barcode scanner used here runs on the device. Check the current ML Kit documentation for the exact behaviour of each feature.

Is it safe to open scanned links?

Treat them as untrusted. Show the address, warn the user, and open only after a tap. Consider checking against a safe browsing service for production apps.