OCR con la privacidad primero en Android: cómo Subly lee facturas sin la nube
Una guía práctica de 2026 sobre OCR en el dispositivo en Android con ML Kit Text Recognition: la configuración, el código y los trucos de extracción de campos que mantienen los datos del usuario fuera de tus servidores.
En el momento en que un usuario apunta su teléfono a una factura, tu aplicación tiene que elegir. Puedes enviar esa imagen a un servicio de OCR en la nube y recibir texto pulido en 400 ms, a costa de hacer pasar el documento financiero privado de alguien por tus servidores y por un tercero. O puedes hacer el trabajo en el dispositivo, escribir una política de privacidad más honesta y no tener que volver a pensar en que otra filtración de datos sea culpa tuya.
Yo elegí el segundo camino para Subly, un gestor de suscripciones para Android que te permite escanear una factura de Netflix o Spotify y que se rellenen por ti el nombre del servicio, el importe, la frecuencia y la fecha del próximo cargo. Nada sale del teléfono. Este artículo explica cómo funciona esto en 2026: la configuración, el código y los trucos de extracción de campos que aprendí a las malas.
Por qué en el dispositivo, cuando la nube es tan fácil
Las API de OCR en la nube son una sola línea. Google Cloud Vision, AWS Textract, Azure Read: todas te dan resultados nítidos y todas quieren las imágenes de tus usuarios. Para un gestor de suscripciones, esas imágenes pueden incluir:
- El nombre, los últimos 4 dígitos o el correo completo del titular de la cuenta
- Bloques de dirección al pie de una factura impresa
- Detalles del método de pago
- El precio completo, las líneas de detalle y las fechas
Nada de esa lista es algo que quiera dejar en los registros de un tercero. Y desde el punto de vista del producto, “nunca enviamos tus facturas a ningún sitio” es un mensaje de marketing mucho más fuerte que “enviamos tus facturas a Google Cloud y las borramos en un plazo de 30 días”. Así que la arquitectura sigue al valor: mantener la imagen en el dispositivo, persistir solo los campos extraídos y no abrir ninguna conexión a internet para el escaneo.
El byte más privado es el que nunca sale del teléfono.
ML Kit Text Recognition v2: el caballo de batalla
ML Kit Text Recognition v2 de Google es, en 2026, la respuesta aburrida pero correcta para el OCR en el dispositivo en Android. Viene con el modelo latino incluido en tu aplicación (unos 4 MB), se ejecuta en un teléfono de la gama Pixel en decenas de milisegundos y maneja el texto impreso lo bastante bien para recibos, facturas y capturas de pantalla de correos.
Lo que me gusta de él:
- No requiere permiso de red. Puedes publicar una aplicación con
INTERNETeliminado y el OCR sigue funcionando. Eso por sí solo ya justifica el tiempo de integración. - Funciona sin conexión. Los escaneos en modo avión son una característica, no un fallo.
- Modelo por aplicación. Incluir el modelo significa que no hay descargas sorpresa en el primer arranque ni un diálogo de
Reintentarcuando el Wi-Fi del usuario falla. - Gratis. Sin costes de API a medida que creces. Para una aplicación indie, esa es la diferencia entre lanzar y darle demasiadas vueltas.
El compromiso es la precisión en los casos límite: la escritura a mano es una lotería, los recibos densos de impresión térmica pueden confundir el diseño y los sistemas de escritura no latinos necesitan otro modelo. Para el caso de uso de recibo/factura/correo, es más que suficiente.
Configuración en 2026
La dependencia de Gradle:
// app/build.gradle.kts
dependencies {
implementation("com.google.mlkit:text-recognition:16.0.1")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")
}
Y una línea en tu manifiesto para que Play reconozca que el modelo está incluido y se descarga por adelantado:
<application>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr" />
</application>
Esa es toda la configuración. Sin claves de API, sin JSON de cuenta de servicio, sin peticiones firmadas.
Reconocer texto desde un fotograma de CameraX
Aquí está el bucle central. CameraX te alimenta con fotogramas ImageProxy, envuelves uno en un InputImage y el reconocedor te devuelve un objeto Text con párrafos, líneas y rectángulos.
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
private val recognizer = TextRecognition.getClient(
TextRecognizerOptions.DEFAULT_OPTIONS,
)
@androidx.camera.core.ExperimentalGetImage
fun analyzeFrame(image: ImageProxy, onScanned: (String) -> Unit) {
val media = image.image ?: return image.close()
val input = InputImage.fromMediaImage(media, image.imageInfo.rotationDegrees)
recognizer.process(input)
.addOnSuccessListener { result ->
// Whole page as a single string, line breaks preserved.
onScanned(result.text)
}
.addOnCompleteListener { image.close() }
}
Un par de cosas que conviene saber:
- Llama siempre a
image.close()exactamente una vez, incluso en caso de error. El patrón de listeners de ML Kit puede jugártela si se te olvida. result.textes el modo fácil. Para una mejor extracción querrásresult.textBlocks, para poder llevar la cuenta de qué líneas están juntas en el recibo.- ML Kit también devuelve rectángulos. Son útiles para dibujar superposiciones (“detectamos esto”) y para comprobar que el usuario realmente encuadró la factura.
Extracción de campos: del texto en bruto a datos estructurados
El OCR te da texto. El trabajo interesante es convertir ese texto en una suscripción. Para Subly, eso significa cuatro campos:
- Nombre del servicio: “Netflix”, “Spotify”, “ChatGPT Plus”
- Importe: “₺199.00” o “$9.99”
- Frecuencia de facturación: mensual, anual
- Fecha del próximo cargo: la fecha en el futuro
Una canalización pragmática en el dispositivo tiene este aspecto:
data class ExtractedSubscription(
val service: String?,
val amount: Money?,
val cycle: BillingCycle?,
val nextDate: LocalDate?,
)
fun extract(text: String): ExtractedSubscription {
val lines = text.lines().map { it.trim() }.filter { it.isNotEmpty() }
return ExtractedSubscription(
service = findService(lines),
amount = findAmount(lines),
cycle = findCycle(lines),
nextDate = findNextDate(lines),
)
}
Cada función find* es algo pequeño y comprobable. Algunas lecciones de la implementación real:
- El nombre del servicio es el más difícil. Un diccionario de servicios comunes (Netflix, Spotify, ChatGPT, YouTube Premium, Apple Music…) resuelve el 80 % de los casos. Para el resto, la primera línea destacada del recibo o una heurística en torno a la cabecera
From:/Sent by:en los correos de facturas funciona bien. - El importe es el más fácil. Una expresión regular adaptada a la configuración regional como
(?:[₺$€£])\s?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{2})?cubre la mayoría de las monedas. Elimina siempre los separadores de miles antes de parsear. - El ciclo proviene del lenguaje que rodea al importe: “per month”, “/mo”, “monthly”, “aylık”, “yearly”, “/yr”. Un pequeño vocabulario en turco e inglés cubre lo que necesitas.
- La próxima fecha son dos estrategias: recoger frases explícitas (“next billing on”, “renews on”) si están presentes; de lo contrario, inferirla a partir de la última fecha del recibo más un ciclo.
Nada de esto es glamuroso. Todo ello ocurre en el dispositivo, así que ningún fallo significa una factura filtrada.
Muéstrale al usuario el trabajo
Un escáner es una caja negra si no muestras lo que hizo. El patrón de Subly: en cuanto un fotograma coincide, dibuja una tarjeta sobre la vista previa de la cámara con los campos extraídos y un botón de Editar. La confianza en el OCR es frágil; un botón de Editar es honesto y gratuito.
@Composable
fun ExtractedCard(s: ExtractedSubscription, onEdit: () -> Unit, onAccept: () -> Unit) {
Card(...) {
Text("AI detected", style = labelMono)
Text("${s.service ?: "—"} ${s.amount?.format() ?: ""}", style = title)
Text("${s.cycle?.label} · next ${s.nextDate?.format()}", style = body)
Row {
TextButton(onClick = onEdit) { Text("Edit") }
FilledButton(onClick = onAccept) { Text("Add") }
}
}
}
Si faltan dos de los cuatro campos, no lo añadas automáticamente: lleva al usuario al formulario manual precargado con lo que sí conseguiste.
Lo que no obtienes del OCR en el dispositivo
Algunos compromisos honestos:
- Idiomas. Cada sistema de escritura necesita su propio modelo. El latino viene incluido por defecto; el chino, el japonés, el coreano y el devanagari van aparte.
- Diseños tabulares. Si necesitas una reconstrucción exacta de columnas y filas de una factura de varias columnas, escribirás más código de maquetación del que esperas. Para la mayoría de los recibos de suscripciones esto no es un problema.
- Escritura a mano. No la prometas. Incluso los OCR en la nube son poco fiables aquí.
- Tamaño del modelo. Añadir varios modelos de idioma puede aumentar notablemente tu APK. Usa el App Bundle de Play y la entrega por funciones si te importa el tamaño de instalación.
El mensaje de marketing se escribe solo
Una frase que quiero poder decir en nuestra política de privacidad: “la imagen de la factura que capturas no se sube a nuestros servidores ni se conserva tras el procesamiento”. Con ML Kit en el dispositivo, esa frase es literalmente cómo funciona el código. No hay nada de marketing en ella: es una afirmación de arquitectura, y la política de privacidad se deriva de ella en lugar de al revés.
Ese es el verdadero argumento a favor del OCR en el dispositivo en 2026. No es solo que sea un poco más rápido o un poco más barato. Es que “nunca vemos tus datos” es algo que puedes decir porque es verdad, no porque el equipo legal dijo que probablemente estaba bien.
Si estás construyendo algo en el terreno de las suscripciones, los recibos o las finanzas personales y no sabes por dónde tirar, opta por defecto por el dispositivo. La biblioteca es gratuita, la historia de privacidad es a prueba de balas y lo peor que puede pasar es que lances una función que tus competidores literalmente no pueden igualar sin reconstruir su stack.
Subly ya está disponible en Google Play: con la privacidad primero por arquitectura, no por promesa. Más información en la página de la aplicación.
// Lecturas relacionadas
Más del diario
Construir Granyn: un control de presupuesto sin inicio de sesión bancario, en tres tablas
Cómo Granyn controla el gasto en varias divisas y detecta facturas recurrentes sin vincular una sola cuenta bancaria — el esquema Room detrás de todo, y las concesiones que exige.
Construir Stocky: predecir qué se agota en tu cocina — en el propio dispositivo
Cómo Stocky convierte el escaneo de un código de barras y un poco de historial de uso en una lista de la compra que aparece antes de que te quedes sin nada — con todos los cálculos, y todos los datos, guardados en tu teléfono.
Android local-first en 2026: SQLite, Room y mantener los datos del usuario en el dispositivo
Una guía de 2026 para construir apps Android local-first con Room y SQLite — diseño de esquemas, migraciones, WAL, exportaciones y cuándo (y cuándo no) añadir sincronización.