Android QR Code Generator y Scanner con Kotlin y Jetpack Compose — Guía Paso a Paso

- Andrés Cruz - EN In english

Video thumbnail

Siguiendo con la generación de imágenes —como vimos antes al trabajar con el Canvas en Android—, en esta entrada veremos cómo generar un código QR directamente desde una aplicación Android usando Jetpack Compose y la librería ZXing.

¿Qué es un código QR?

Antes de meternos de lleno en la implementación, es importante entender qué es un código QR y cómo funciona su estructura. Esto nos ayudará a tomar mejores decisiones de diseño al integrarlo en nuestras apps.

Un código QR (Quick Response) no es más que un sistema para almacenar información en forma de código de barras de dos dimensiones. A diferencia de los códigos de barras tradicionales —que solo pueden leerse de forma horizontal—, un QR codifica datos tanto en sentido horizontal como vertical, lo que le permite almacenar significativamente más información en el mismo espacio.

Código QR de ejemplo
Ejemplo de un código QR.

Los códigos QR son extremadamente versátiles: sirven para administrar inventarios, compartir URLs, datos de contacto, credenciales de acceso y mucho más. Hoy en día son un elemento casi imprescindible en cualquier aplicación de mediana o gran envergadura, tanto por su facilidad de uso como por la amplia adopción que tienen entre los usuarios.

¿Para qué sirve un código QR y cómo podemos explotarlo en nuestras apps Android?

Los códigos QR se utilizan en prácticamente todos los ámbitos: desde el etiquetado de productos físicos como alimentos, medicamentos y dispositivos electrónicos, hasta el marketing digital, los sistemas de pago y la vinculación de cuentas entre plataformas. En una app Android, puedes integrar un lector QR para permitirle al usuario escanear cualquier tipo de código sin necesidad de salir de tu aplicación.

Un caso de uso concreto: imagina una app de farmacia en la que el cliente escanea el código QR de un medicamento y el sistema —con el perfil del usuario ya cargado— indica automáticamente si ese fármaco es compatible con sus tratamientos actuales, sin que el usuario tenga que ingresar ningún dato de forma manual.

¿Cómo generar un código QR?

En internet encontrarás múltiples formas de generar códigos QR: existen librerías para PHP, Java, Python, Kotlin y muchos otros lenguajes. En este blog encontrarás artículos sobre cómo generar QRs con distintas tecnologías; hoy nos centraremos en la generación programática desde Android con ZXing.

Generación de códigos QR en Android con Jetpack Compose

Vamos a aprender a generar códigos QR directamente desde una aplicación Android con Jetpack Compose. Esto es especialmente útil hoy en día, ya que casi cualquier app compleja requiere este tipo de mecanismo: desde plataformas como Netflix (para vincular dispositivos) hasta pasarelas de pago o sistemas de autenticación. Los QRs son la evolución natural de los códigos de barras, y su integración en Android es más sencilla de lo que parece.

1. Configuración de la dependencia

Para generar QRs utilizaremos la librería ZXing (Zebra Crossing), una de las más conocidas del ecosistema Android. Permite tanto escanear como generar códigos QR y de barras.

Abre tu archivo app/build.gradle.kts y añade la siguiente dependencia:

implementation("com.google.zxing:core:3.5.4")

Como siempre, verifica que estás usando la versión más reciente: si posicionas el cursor sobre la dependencia en Android Studio, el IDE te sugerirá si hay una versión más nueva disponible. Una vez añadida, pulsa Sync Now y espera a que finalice la sincronización para evitar errores de importación.

Lógica de la función: de datos a Bitmap

Crearemos una función de nivel superior (fuera de cualquier clase o @Composable) para que sea accesible desde cualquier parte del proyecto. Esta función recibirá dos parámetros: el contenido que queremos embeber en el QR (texto, URL o número) y el tamaño en píxeles de la imagen resultante.

El concepto de BitMatrix y Bitmap

La función devolverá un objeto Bitmap. Un Bitmap es una representación matricial de una imagen, donde cada posición (x, y) almacena información de color en formato de píxel. El proceso de generación del QR sigue tres pasos:

  1. Codificación: Usamos QRCodeWriter().encode() para convertir los datos en una BitMatrix, que es la representación lógica del QR (una cuadrícula de puntos encendidos y apagados).
  2. Dimensiones: Obtenemos el ancho y alto de la BitMatrix para crear un Bitmap del tamaño exacto.
  3. Creación del Bitmap: Instanciamos el objeto con createBitmap(). Verás en internet ejemplos con la API antigua Bitmap.createBitmap, pero la forma moderna usa la función de extensión de Kotlin con parámetros nominales, especificando el formato de color: Bitmap.Config.RGB_565 (menos memoria) o ARGB_8888 (con canal alfa, ideal si necesitas transparencia).

Renderizado de la imagen en Kotlin

Para construir la imagen visual, recorremos la BitMatrix píxel por píxel. Como un QR es tradicionalmente blanco y negro, la lógica es simple:

  • Si en la posición (x, y) la BitMatrix tiene un dato activo, pintamos ese píxel de Color.BLACK.
  • Si no, lo pintamos de Color.WHITE.
fun generarQR(datos: String, size: Int = 512): Bitmap? {
    return try {
        val writer = QRCodeWriter()
        // Crea la matriz de puntos (BitMatrix)
        val bitMatrix = writer.encode(datos, BarcodeFormat.QR_CODE, size, size)
        val width = bitMatrix.width
        val height = bitMatrix.height
//        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
        val bitmap = createBitmap(width, height, Bitmap.Config.RGB_565)

        // Recorremos la matriz para pintar píxel por píxel
        for (x in 0 until width) {
            for (y in 0 until height) {
                 bitmap[x, y] = if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE
                // bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
            }
        }
        bitmap
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

La función está envuelta en un bloque try/catch: si la codificación falla (datos vacíos, tamaño inválido, etc.), retornamos null en lugar de romper la app, lo que nos permite manejar el error de forma controlada en la UI.

Consumo en la interfaz con Jetpack Compose

Para mostrar el QR generado en nuestra interfaz, crearemos un @Composable que siga estos pasos:

  1. Uso de remember: Llamamos a generarQR() dentro de remember(textoParaQR). Esto es crítico para que el QR no se regenere en cada recomposición de la UI —lo que sería muy costoso en rendimiento—. Al pasar textoParaQR como clave, el QR solo se regenerará cuando ese valor cambie.
  2. Conversión a ImageBitmap: El composable Image de Compose espera un ImageBitmap, no un android.graphics.Bitmap. Para la conversión usamos la extensión .asImageBitmap().
  3. Interfaz de usuario: Añadimos el componente Image con su descripción de accesibilidad, un Spacer para separación visual y un Text descriptivo.
@Composable
fun PantallaQR(textoParaQR: String) {
    // Generamos el bitmap y lo recordamos para evitar regeneraciones innecesarias
    val qrBitmap = remember(textoParaQR) {
        generarQR(textoParaQR)
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        if (qrBitmap != null) {
            Image(
                bitmap = qrBitmap.asImageBitmap(),
                contentDescription = "Código QR de $textoParaQR",
                modifier = Modifier.size(250.dp)
            )
            Spacer(modifier = Modifier.height(16.dp))
            Text(text = "Escanea para ver el contenido", style = MaterialTheme.typography.bodyMedium)
        } else {
            Text("Error al generar el QR")
        }
    }
}

Al ejecutar la aplicación obtendremos un código QR renderizado directamente en pantalla:

Código QR generado de desarrollolibre.net
Ejemplo de QR generado para probar el escáner.

Crear un lector de códigos QR con Jetpack Compose

Para construir un escáner QR con Jetpack Compose, combinaremos CameraX para el acceso a la cámara con ML Kit Barcode Scanning para el análisis de los frames. Esta es la solución moderna y recomendada por Google para cualquier nueva app Android.

Ventajas de Jetpack Compose para un lector QR:

  • Declarativo: Defines la UI una sola vez y Compose gestiona los cambios de estado automáticamente, reduciendo el código reactivo manual.
  • Menos código: Se requiere significativamente menos código que con el enfoque tradicional de XML + Activities.
  • Reutilización: Los @Composable son fáciles de combinar y reutilizar en distintas partes de la app.
  • Compatibilidad nativa con Kotlin: Compose fue diseñado desde cero para Kotlin, aprovechando corrutinas, lambdas y funciones de extensión.
  • Integración con librerías modernas: Se integra perfectamente con CameraX y ML Kit para una experiencia de desarrollo actualizada y con soporte activo de Google.

1. Dependencias (build.gradle del módulo):

// Jetpack Compose
implementation platform('androidx.compose:compose-bom:2024.04.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.activity:activity-compose:1.8.2' // O la versión más reciente

// CameraX
implementation 'androidx.camera:camera-core:1.3.2' // O la versión más reciente
implementation 'androidx.camera:camera-camera2:1.3.2'
implementation 'androidx.camera:camera-lifecycle:1.3.2'
implementation 'androidx.camera:camera-view:1.3.2' // Para CameraPreview Composable

// ML Kit Barcode Scanning
implementation 'com.google.mlkit:barcode-scanning:17.2.0' // O la versión más reciente
// Para modelos con Google Play Services (reconocimiento más rápido/preciso)
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0'

2. Permisos (AndroidManifest.xml):

Al igual que en el enfoque legacy, es obligatorio declarar el permiso de cámara. El manejo en tiempo de ejecución puede hacerse con rememberPermissionState de Accompanist Permissions o directamente con rememberLauncherForActivityResult de AndroidX, como verás en el ejemplo siguiente.

<uses-permission android:name="android.permission.CAMERA" />      

3. Estructura del Composable (ejemplo completo):

A continuación te mostramos cómo estructurar un @Composable para mostrar la vista previa de la cámara e integrar el análisis de códigos QR. La lógica compleja se abstrae en la clase auxiliar BarcodeAnalyzer, manteniendo el Composable principal limpio y centrado en la UI.

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
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.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
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
import java.util.concurrent.Executors

// Composable principal para el lector QR
@Composable
fun QRCodeScannerScreen() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    var hasCameraPermission by remember { mutableStateOf(false) }
    var qrCodeResult by remember { mutableStateOf("Escaneando...") }

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { isGranted ->
            hasCameraPermission = isGranted
            if (!isGranted) {
                qrCodeResult = "Permiso de cámara denegado."
            }
        }
    )

    LaunchedEffect(Unit) {
        when {
            ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
                hasCameraPermission = true
            }
            else -> {
                permissionLauncher.launch(Manifest.permission.CAMERA)
            }
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        if (hasCameraPermission) {
            CameraPreview(onQrCodeDetected = { result ->
                qrCodeResult = result
            })
            Spacer(Modifier.height(16.dp))
            Text(text = "Resultado: $qrCodeResult", modifier = Modifier.padding(16.dp))
        } else {
            Text("Necesitamos permiso de cámara para escanear QR.", modifier = Modifier.padding(16.dp))
            Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
                Text("Conceder Permiso")
            }
        }
    }
}

@Composable
fun CameraPreview(onQrCodeDetected: (String) -> Unit) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraExecutor = remember { Executors.newSingleThreadExecutor() }

    AndroidView(
        modifier = Modifier
            .fillMaxWidth()
            .weight(1f),
        factory = { ctx ->
            val previewView = PreviewView(ctx)
            val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)

            cameraProviderFuture.addListener({
                val cameraProvider = cameraProviderFuture.get()
                val preview = Preview.Builder().build().also {
                    it.setSurfaceProvider(previewView.surfaceProvider)
                }

                val imageAnalyzer = ImageAnalysis.Builder()
                    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_LATEST)
                    .build()
                    .also {
                        it.setAnalyzer(cameraExecutor, BarcodeAnalyzer(onQrCodeDetected))
                    }

                val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                try {
                    cameraProvider.unbindAll()
                    cameraProvider.bindToLifecycle(
                        lifecycleOwner,
                        cameraSelector,
                        preview,
                        imageAnalyzer
                    )
                } catch (exc: Exception) {
                    // Manejar errores de inicialización de cámara
                }
            }, ContextCompat.getMainExecutor(ctx))
            previewView
        }
    )
    DisposableEffect(Unit) {
        onDispose {
            cameraExecutor.shutdown()
        }
    }
}

// Clase para analizar los frames de la cámara
class BarcodeAnalyzer(private val onQrCodeDetected: (String) -> Unit) : ImageAnalysis.Analyzer {
    private val scanner = BarcodeScanning.getClient(
        BarcodeScannerOptions.Builder()
            .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
            .build()
    )

    override fun analyze(imageProxy: ImageProxy) {
        val mediaImage = imageProxy.image
        if (mediaImage != null) {
            val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
            scanner.process(image)
                .addOnSuccessListener { barcodes ->
                    if (barcodes.isNotEmpty()) {
                        barcodes.firstOrNull()?.rawValue?.let { rawValue ->
                            onQrCodeDetected(rawValue)
                        }
                    }
                }
                .addOnCompleteListener {
                    imageProxy.close() // Importante: cerrar ImageProxy para liberar el buffer
                }
        } else {
            imageProxy.close()
        }
    }
}

Analicemos las responsabilidades de cada componente:

  • QRCodeScannerScreen: el @Composable principal. Gestiona el estado del permiso y del resultado. Solicita el permiso con LaunchedEffect al montarse.
  • CameraPreview: envuelve un AndroidView para integrar el PreviewView de CameraX, ya que aún no existe un Composable nativo para la vista previa de cámara.
  • BarcodeAnalyzer: implementa ImageAnalysis.Analyzer y usa ML Kit para procesar cada frame. Es imprescindible llamar a imageProxy.close() en addOnCompleteListener para liberar el buffer y no bloquear el pipeline de la cámara.
  • El resultado se devuelve al Composable principal mediante el callback onQrCodeDetected, manteniendo el flujo de datos unidireccional.

Este enfoque moderno simplifica considerablemente la configuración de la UI y mejora la legibilidad del código, aprovechando el paradigma declarativo de Jetpack Compose junto con ML Kit y CameraX.

Enfoque Legacy con XML (para referencia histórica)

En esta sección veremos cómo implementar un lector QR usando el enfoque clásico basado en XML y Activities. Aunque ya no es el método recomendado para nuevos proyectos, es útil para entender la evolución del desarrollo Android o para dar mantenimiento a apps con código legacy.

Desarrollando un lector QR con la librería de Play Services

Google pone a disposición de los desarrolladores una amplia cantidad de APIs a través de sus librerías de Play Services. Para tareas comunes como leer un código QR, no es necesario recurrir a librerías de terceros: la propia librería de Google hace todo el trabajo pesado por nosotros.

Nota de actualización: La librería com.google.android.gms:play-services-vision ha sido deprecada. Para nuevas implementaciones, Google recomienda utilizar ML Kit Barcode Scanning (com.google.mlkit:barcode-scanning o com.google.android.gms:play-services-mlkit-barcode-scanning) en combinación con CameraX para la integración de la cámara.

Crear un lector QR personalizado en Android Studio es bastante sencillo: solo debemos incluir la librería externa correspondiente a los servicios de Google Play y configurar algunos componentes.

Escáner QR: ¿cómo descifrar un código QR?

Con la ayuda del móvil podemos recuperar la información almacenada en un QR con solo apuntar la cámara hacia él. Esa es exactamente la idea de lo que implementaremos en esta sección.

Desarrollando nuestro propio lector de códigos QR en Android

Primero debemos agregar la dependencia necesaria en nuestro archivo build.gradle.

Agregando la dependencia en el archivo build.gradle

Abrimos el archivo build.gradle a nivel de módulo y agregamos la siguiente dependencia:

implementation 'com.google.android.gms:play-services:15.0.2'

Nota de actualización: La dependencia play-services es demasiado amplia e incluye APIs que probablemente no necesites. Es mejor usar solo los módulos requeridos. Además, play-services-vision está deprecada. La opción moderna recomendada es ML Kit Barcode Scanning con CameraX.

Si solo nos interesa la API de vision para leer QRs, podemos usar la dependencia más específica en nuestro build.gradle:

implementation 'com.google.android.gms:play-services-vision:15.0.2'

Las versiones se actualizan con frecuencia. Siempre puedes consultar la última disponible en la documentación oficial:

Set Up Google Play Services

Nota de actualización: Usar rangos de versión como 15.+ no es una práctica recomendada en producción, ya que puede introducir incompatibilidades inesperadas. Siempre especifica una versión exacta.

Si quisieras que la dependencia se actualice automáticamente a sub-versiones de la 15.x: implementation 'com.google.android.gms:play-services-vision:15.+'

Configurando el resto de la aplicación para el lector QR

El archivo manifest para manejar los permisos

En nuestro AndroidManifest.xml declaramos el uso de la cámara:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="net.desarrollolibre.qr.qr">
    <uses-permission android:name="android.permission.CAMERA" />
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

La vista de nuestro lector de códigos QR

Con la dependencia ya configurada, podemos ir a nuestra Activity o Fragment a crear los objetos y configuraciones necesarios. Pero primero, definamos el layout con la vista de la cámara:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:layout_gravity="center_horizontal"
        android:padding="8dp">
        <SurfaceView
            android:id="@+id/camera_view"
            android:layout_width="640px"
            android:layout_height="480px"
            android:layout_alignParentLeft="true"
            android:layout_centerVertical="true" />
    </LinearLayout>
</LinearLayout>

Configurando el detector y la cámara en nuestra Activity

En nuestra Activity o Fragment creamos un objeto de tipo BarcodeDetector. Esta clase permite reconocer códigos de barras y QR a través de la cámara, devolviendo un SparseArray con el dato decodificado. A continuación también creamos el CameraSource:

        // Creo el detector QR
        barcodeDetector =
                new BarcodeDetector.Builder(getContext())
                        .setBarcodeFormats(Barcode.QR_CODE)
                        .build();
        // Creo la fuente de cámara
        cameraSource = new CameraSource
                .Builder(getContext(), barcodeDetector)
                .setRequestedPreviewSize(640, 480)
                .build();

El objeto CameraSource para obtener frames de la cámara

Referenciamos el SurfaceView de nuestro layout; este componente refleja en pantalla lo que está captando la cámara del dispositivo:

cameraView = (SurfaceView) v.findViewById(R.id.camera_view);

A continuación, registramos el listener del ciclo de vida del SurfaceHolder para iniciar y detener la cámara en el momento correcto:

        // Listener de ciclo de vida de la cámara
        cameraView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                if (ContextCompat.checkSelfPermission(getContext(), android.Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
                    try {
                        cameraSource.start(cameraView.getHolder());
                    } catch (IOException ie) {
                        Log.e("CAMERA SOURCE", ie.getMessage());
                    }
                } else {
                    Toast.makeText(getContext(), getResources().getString(R.string.error_camara), Toast.LENGTH_SHORT).show();
                }
            }
            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            }
            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {
                cameraSource.stop();
            }
        });

Un punto clave: la cámara debe iniciarse con cameraSource.start(cameraView.getHolder()) dentro del callback surfaceCreated, es decir, una vez que la superficie ya ha sido dibujada. Intentar arrancarla antes provocará un error.

Por último, preparamos el evento que retornará el resultado del QR detectado. Cuando barcodes.size() sea mayor a cero, significa que se ha leído al menos un código:

        // Preparo el detector de QR
        barcodeDetector.setProcessor(new Detector.Processor<Barcode>() {
            @Override
            public void release() {
            }
            @Override
            public void receiveDetections(Detector.Detections<Barcode> detections) {
                final SparseArray<Barcode> barcodes = detections.getDetectedItems();
                if (barcodes.size() != 0) {
                    barcodes.valueAt(0).displayValue.toString();
                    // hacer algo con el valor obtenido
                }
                barcodeDetector.release();
            }
        });

Con barcodes.valueAt(0).displayValue.toString() obtenemos el dato devuelto por el QR: puede ser texto plano, un número o una URL, dependiendo de cómo haya sido generado el código.

Los permisos en Android 6 y superior

Nota de actualización: Las clases ActivityCompat y AppCompatActivity del código original pertenecen a las bibliotecas de soporte legadas (android.support.*). En proyectos modernos han sido reemplazadas por AndroidX (androidx.core.app.ActivityCompat y androidx.appcompat.app.AppCompatActivity). El manejo de permisos sigue un patrón similar, pero siempre conviene consultar la documentación más reciente de AndroidX.

A partir de Android 6 (API 23), Google introdujo un nuevo esquema de permisos en tiempo de ejecución: los permisos sensibles —como el acceso a la cámara— deben solicitarse explícitamente al usuario, no solo declararse en el AndroidManifest.

Por lo tanto, además de la declaración en el manifest, debemos solicitar el permiso desde el código de nuestra Activity. Añadimos esta lógica al construir el SurfaceHolder:

// Listener de ciclo de vida de la cámara
cameraView.getHolder().addCallback(new SurfaceHolder.Callback() {
   @Override
   public void surfaceCreated(SurfaceHolder holder) {
       if (ActivityCompat.checkSelfPermission(QRActiviy.this, Manifest.permission.CAMERA)
               != PackageManager.PERMISSION_GRANTED) {
           if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
               if (shouldShowRequestPermissionRationale(
                       Manifest.permission.CAMERA)) ;
               requestPermissions(new String[]{Manifest.permission.CAMERA},
                       MY_PERMISSIONS_REQUEST_CAMERA);
           }
           return;
       } else {
           try {
               cameraSource.start(cameraView.getHolder());
           } catch (IOException ie) {
               Log.e("CAMERA SOURCE", ie.getMessage());
           }
       }
   }
   @Override
   public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
   }
   @Override
   public void surfaceDestroyed(SurfaceHolder holder) {
       cameraSource.stop();
   }
});

Verificamos si el permiso ya fue concedido con checkSelfPermission y, en caso negativo, mostramos el diálogo nativo con shouldShowRequestPermissionRationale seguido de requestPermissions.

El código completo de la Activity quedaría así:

import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.support.v4.app.ActivityCompat; // Usar androidx.core.app.ActivityCompat
import android.support.v7.app.AppCompatActivity; // Usar androidx.appcompat.app.AppCompatActivity
import android.os.Bundle;
import android.util.Log;
import android.util.SparseArray;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.webkit.URLUtil;
import com.google.android.gms.vision.CameraSource; // Deprecado, usar ML Kit Barcode Scanning
import com.google.android.gms.vision.Detector; // Deprecado, usar ML Kit Barcode Scanning
import com.google.android.gms.vision.barcode.Barcode; // Deprecado, usar ML Kit Barcode Scanning
import com.google.android.gms.vision.barcode.BarcodeDetector; // Deprecado, usar ML Kit Barcode Scanning
import java.io.IOException;
public class MainActivity extends AppCompatActivity {
    private CameraSource cameraSource;
    private SurfaceView cameraView;
    private final int MY_PERMISSIONS_REQUEST_CAMERA = 1;
    private String token = "";
    private String tokenanterior = "";
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        cameraView = (SurfaceView) findViewById(R.id.camera_view);
        initQR();
    }
    public void initQR() {
        BarcodeDetector barcodeDetector =
                new BarcodeDetector.Builder(this)
                        .setBarcodeFormats(Barcode.ALL_FORMATS)
                        .build();
        cameraSource = new CameraSource
                .Builder(this, barcodeDetector)
                .setRequestedPreviewSize(1600, 1024)
                .setAutoFocusEnabled(true) // Recomendado para un escaneo más preciso
                .build();
        cameraView.getHolder().addCallback(new SurfaceHolder.Callback() {
            @Override
            public void surfaceCreated(SurfaceHolder holder) {
                if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA)
                        != PackageManager.PERMISSION_GRANTED) {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                        if (shouldShowRequestPermissionRationale(
                                Manifest.permission.CAMERA)) ;
                        requestPermissions(new String[]{Manifest.permission.CAMERA},
                                MY_PERMISSIONS_REQUEST_CAMERA);
                    }
                    return;
                } else {
                    try {
                        cameraSource.start(cameraView.getHolder());
                    } catch (IOException ie) {
                        Log.e("CAMERA SOURCE", ie.getMessage());
                    }
                }
            }
            @Override
            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
            }
            @Override
            public void surfaceDestroyed(SurfaceHolder holder) {
                cameraSource.stop();
            }
        });
        barcodeDetector.setProcessor(new Detector.Processor<Barcode>() {
            @Override
            public void release() {
            }
            @Override
            public void receiveDetections(Detector.Detections<Barcode> detections) {
                final SparseArray<Barcode> barcodes = detections.getDetectedItems();
                if (barcodes.size() > 0) {
                    token = barcodes.valueAt(0).displayValue.toString();
                    if (!token.equals(tokenanterior)) {
                        tokenanterior = token;
                        Log.i("token", token);
                        if (URLUtil.isValidUrl(token)) {
                            Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(token));
                            startActivity(browserIntent);
                        } else {
                            Intent shareIntent = new Intent();
                            shareIntent.setAction(Intent.ACTION_SEND);
                            shareIntent.putExtra(Intent.EXTRA_TEXT, token);
                            shareIntent.setType("text/plain");
                            startActivity(shareIntent);
                        }
                        new Thread(new Runnable() {
                            public void run() {
                                try {
                                    synchronized (this) {
                                        wait(5000);
                                        tokenanterior = "";
                                    }
                                } catch (InterruptedException e) {
                                    Log.e("Error", "Waiting didn't work!!");
                                    e.printStackTrace();
                                }
                            }
                        }).start();
                    }
                }
            }
        });
    }
}

Lector de códigos QR en Kotlin (versión legacy)

La misma implementación pero en Kotlin. Nota los cambios idiomáticos: el uso de val y var, la sintaxis object : para implementaciones anónimas, la llamada segura con !! en nulables y la ausencia de punto y coma:

    private var cameraSource: CameraSource? = null
    private var cameraView: SurfaceView? = null
    private val MY_PERMISSIONS_REQUEST_CAMERA = 1
    private var token = ""
    private var tokenanterior = ""
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        cameraView = findViewById<View>(R.id.camera_view)
        initQR()
    }
    fun initQR() {
        val barcodeDetector = BarcodeDetector.Builder(this)
                .setBarcodeFormats(Barcode.ALL_FORMATS)
                .build()
        cameraSource = CameraSource.Builder(this, barcodeDetector)
                .setRequestedPreviewSize(1600, 1024)
                .setAutoFocusEnabled(true)
                .build()
        cameraView!!.holder.addCallback(object : SurfaceHolder.Callback {
            override fun surfaceCreated(holder: SurfaceHolder) {
                if (ActivityCompat.checkSelfPermission(this@MainActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                        if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA))
                        ;
                        requestPermissions(arrayOf(Manifest.permission.CAMERA), MY_PERMISSIONS_REQUEST_CAMERA)
                    }
                    return
                } else {
                    try {
                        cameraSource!!.start(cameraView!!.holder)
                    } catch (ie: IOException) {
                        Log.e("CAMERA SOURCE", ie.message)
                    }
                }
            }
            override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
            override fun surfaceDestroyed(holder: SurfaceHolder) {
                cameraSource!!.stop()
            }
        })
        barcodeDetector.setProcessor(object : Detector.Processor<Barcode> {
            override fun release() {}
            override fun receiveDetections(detections: Detector.Detections<Barcode>) {
                val barcodes = detections.getDetectedItems()
                if (barcodes.size() > 0) {
                    token = barcodes.valueAt(0).displayValue.toString()
                    if (token != tokenanterior) {
                        tokenanterior = token
                        Log.i("token", token)
                        if (URLUtil.isValidUrl(token)) {
                            val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(token))
                            startActivity(browserIntent)
                        } else {
                            val shareIntent = Intent()
                            shareIntent.action = Intent.ACTION_SEND
                            shareIntent.putExtra(Intent.EXTRA_TEXT, token)
                            shareIntent.type = "text/plain"
                            startActivity(shareIntent)
                        }
                        Thread(object : Runnable {
                            override fun run() {
                                try {
                                    synchronized(this) {
                                        wait(5000)
                                        tokenanterior = ""
                                    }
                                } catch (e: InterruptedException) {
                                    Log.e("Error", "Waiting didn't work!!")
                                    e.printStackTrace()
                                }
                            }
                        }).start()
                    }
                }
            }
        })
    }

En el código anterior puedes ver el flujo completo de detección: obtenemos el valor del QR con barcodes.valueAt(0).displayValue.toString(), lo validamos para evitar procesar el mismo código consecutivamente (gracias al mecanismo de tokenanterior y el Thread con wait(5000)), y finalmente con URLUtil.isValidUrl(token) determinamos si el contenido es una URL —en cuyo caso abrimos el navegador— o texto plano, que compartimos con otras apps mediante un Intent.ACTION_SEND.

Al ejecutar la aplicación obtendremos una pantalla como la siguiente:

App Android con lector QR ejecutándose en Android Studio
Captura de pantalla de la aplicación ejecutándose con el lector QR (enfoque legacy).

Recuerda que una vez que obtienes el código puedes hacer lo que necesites con él: enviarlo a tu backend para validarlo, mostrarlo en un dialog o abrir el navegador. Si te interesa generar QRs desde el servidor, puedes revisar el artículo sobre cómo crear códigos QR en CodeIgniter.

Puedes usar el siguiente QR para probar tu escáner; apunta la cámara y debería devolverte la URL de este blog:

QR de desarrollolibre.net para probar el escáner Android
Código QR de prueba — escanéalo para verificar que el lector funciona correctamente.

Un detalle importante: este mismo código sirve para leer códigos de barras (barcodes) además de QRs. El core del lector es idéntico; simplemente ajustando el parámetro en setBarcodeFormats() puedes soportar EAN-13, UPC-A, Code 128 y muchos más formatos. Con eso, puedes construir una app bastante potente que funcione como lector universal de códigos.

Siguiente paso: aprende a usar Room Database (SQLite) en Android Studio con Kotlin.

Crea tu propio generador y lector de códigos QR en Android usando Kotlin, ZXing, CameraX y ML Kit Barcode Scanning con Jetpack Compose. Código completo explicado para Android Studio.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.