Lost in Translation: El bug que hablaba Ruso y rompía mi App

(Actualizado el )
Espía mirando pantallas.
Espía mirando pantallas.
Otros idiomas: English

Introducción

Hace unos años estuve trabajando en Lipo Manager para añadir algunas cosas que tenía pendiente desde hacía algún tiempo. La aplicación es bastante simple pero más que suficiente para el cometido de llevar un control de las LiPos (un tipo de batería). Algunos de estos cambios venían impulsados por la comunidad, que pedía algunas funcionalidades que aún no habían sido implementada: mejora del aspecto visual, optimización, implementar multi-idiomas, actualizar dependencias y arreglar algún que otro ocasional NullPointerException.

En un día de trabajo pude terminarlo todo y, tras unas cuantas pruebas, lancé la nueva versión.

“No puedo entrar en la app”

A los pocos días me llegó un mensaje de un usuario a través del canal de Telegram:

Captura de un usuario explicando que tiene problemas al entrar en la app tras actualizar su teléfono.

He actualizado el teléfono y la aplicación ha dejado de funcionar.

Vaya…

Uno de los primeros pasos que todo desarrollador conoce es que para encontrar solucionar un bug, lo primero hay que intentar replicarlo. Por ello, es útil conocer el entorno en el que ocurre. Por lo que le pido información del sistema a lo que el accede con gusto.

Lo primero que me interesa es saber si la versión de Android del usuario del usuario es, o muy nueva (beta), o muy antigua. En general, quería verificar si se trataba de alguna versión que no hubiera testeado y que tuviera algún problema con alguna librería que utiliza la aplicación. Para mi sorpresa, su teléfono estaba ejecutando Android 13. Justamente la misma versión y apis con las que más había testeado la app.

Tendría que indagar un poco más.

Mirando logs en la Play Console

Google dispone a los desarrolladores de muchas herramientas para gestionar las apps publicadas en la Play Store. Una de ellas es Android Vitals, que recoge información sobre cada instalación y en caso de ocurrir alguna excepción, Android recoge todas las trazas y las pone a disposición del desarrollador junto a muchos más detalles.

No entraré a dar mi opinión sobre lo que me parece esto y de si vulnera la privacidad o no, pero lo cierto es que cuando las cosas van mal es inviable pedirle al usuario que se conecte por ADB al teléfono, saque las trazas y nos la mande. Por lo que a fin de cuenta, es una herramienta muy útil.

Lo que me mostraba las trazas de un par de usuarios era lo siguiente:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.dropvoid.lipomanager/com.dropvoid.lipomanager.MainActivity}: android.database.sqlite.SQLiteException: not an error (code 0 SQLITE_OK)
	at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3782)
	...
	at android.app.ActivityThread.main(ActivityThread.java:8176)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:552)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:971)
Caused by: android.database.sqlite.SQLiteException: not an error (code 0 SQLITE_OK)
	at android.database.sqlite.SQLiteConnection.nativeRegisterLocalizedCollators(Native Method)
	at android.database.sqlite.SQLiteConnection.setLocaleFromConfiguration(SQLiteConnection.java:460)
	at android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:272)
	...
	at android.database.sqlite.SQLiteDatabase.open(SQLiteDatabase.java:1067)
	at android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:931)
	at android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:920)
	at android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:373)
	at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:316)
	at com.orm.SugarDb.getDB(SugarDb.java:38)
	at com.orm.SugarRecord.getSugarDataBase(SugarRecord.java:35)
	at com.orm.SugarRecord.find(SugarRecord.java:201)
	at com.orm.SugarRecord.listAll(SugarRecord.java:127)
	at com.dropvoid.lipomanager.services.BatteryService.loadAllBatteries(BatteryService.java:21)
	...

¿Un problema con la base de datos? Lo primero que pienso es que es posible que la haya liado actualizando SugarORM. Esta librería es la que usa la app como ORM gestionar la base de datos. Sin embargo, yo no había tenido ningún problema así que dudaba que fuera la causante directamente. Por tanto, ¿cuál era la condición que lo provocaba?

Comienza la odisea

Está bien. Solo tenemos que replicar el problema y empezar a seguir el rastro hasta el bug, ¿no? Algo sencillo. Una rutina. Pronto estaría resuelto.

+15 horas después

(Por desgracia) todo funcionaba bien. No había manera de hacer que esto explotara. Y eso era lo único que deseaba.

Internet no me arrojaba demasiada luz ya que todo el mundo parecía apuntar a causas como permisos en el fichero de la db o fallos de sincronización entre otros. Ninguno de estos parecía ser la causa o tener algún tipo de sentido para mi caso. De cualquier manera no podría saberlo porque no había manera de replicar el problema.

Como no podía replicarlo, tampoco podía hacer rollback de los cambios. Más que nada porque ni siquiera sabía a ciencia cierta si este problema venía de mucho antes.

Google me proporcionaba también la versión de Android, el modelo del teléfono, etc. Ni siquiera emulando exactamente las características anteriores conseguía ningún progreso las características anteriores conseguía ningún progreso.

En este punto había maltratado la aplicación de todas las formas posibles e irónicamente me fastidiaba que fuera tan robusta; había corrompido la base de datos, había insertado miles de registros, había aislado todo lo relacionado con la db del resto de la aplicación, había simulado decenas de migraciones entre versiones… nada de eso causaba el problema que estaba buscando.

El método BatteryService.loadAllBatteries() era demasiado simple y no tenía mucha lógica. Además, era uno de los primeros métodos en llamarse al abrir la app así que no había muchas posibilidades de que se pudiera dar una condición de carrera o algo así.

Cada vez que crecía mi desesperación y re-leía las trazas el mensaje me parecía cada vez más insultante: “SQLiteException: not an Error. Todo OK“.

En este punto estaba estancado. Ya había perdido demasiado el tiempo con todo esto y empecé a sentir mi derrota. Ya no sabía nada más que hacer.

Con resignación empecé a cerrar todo el tinglado que tenía montado…

Un giro inesperado

Ya solo me quedaban unas pocas ventanas por cerrar. Justo delante mía tenía la página de Google Play y quise echar un último vistazo a la ficha del dispositivo del usuario por si había algo que hubiera podido pasar por alto. Ya la tenía casi memorizada después de tanto tiempo peleando con ella; dispositivo, versiones, trazas, n aplicaciones instaladas, características, país… ¿país?

El país del usuario era el único detalle que había pasado por alto, pero que ahora resultaba ser el único aspecto que no había considerado en absoluto. ¿Qué importancia tenía que ese usuario fuera ruso? ¿Acaso un móvil ruso era diferente? Bueno, vamos a probarlo y realizar algunas pruebas más. Si total…

* Cambiamos el idioma del móvil a Ruso.

Captura de ajuste de idioma en Android. Ruso e ingles. Dios me ayude cuando tenga que poner esto de vuelta en inglés.

* Abrimos la app…

The bug

OMG AHÍ ESTÁ. AL FIN.

Se abren las puertas

En una mezcla de frustración y felicidad había conseguido replicar el problema. Ahora tenía más información para seguir investigando pero no importaba. Ahora ya sabía que se trataba de algún tipo de problema con la codificación de los caracteres y lo único que quería era dejar esto funcionando.

Ahora ya podía buscar en Google y rápidamente encontré esta pregunta de stackoverflow.

En mi código, una de las primeras rutinas era la de cargar el idioma del dispositivo para, en caso de tener un fichero de traducción del mismo, cargarla como idioma en la app.

public void updateAppLanguage(Context context) {
    String languageCode = Locale.getDefault().getDisplayLanguage();
    Locale locale = new Locale(languageCode);
    Locale.setDefault(locale);
    ...

En el caso de tener el dispositivo en ruso, Locale.getDefault() devuelve ”русский”, y parece que esto a SqlLite no le gusta nada por algún motivo.

La solución definitiva pasaba por comprobar manualmente ese caso tan específico:

public void updateAppLanguage(Context context) {
    String languageCode = Locale.getDefault().getDisplayLanguage();

    // Sql crashes at startup when the language is русский.
    // We switch to RU and manage it manually with languages.
    if (languageCode.equals("русский")) {
        languageCode = "ru";
    }

    Locale locale = new Locale(languageCode);
    Locale.setDefault(locale);
    ...

3 lineas de código arreglaban el problemón que tantos dolores de cabeza me había estado dando. Una vez más.

Estuve haciendo más pruebas y parece que de los más de 100 idiomas que tiene Android, solo la codificación del ruso rompía sqlite. Ni siquiera el chino, que es el que típicamente solemos pensar como el más complicado (y además justamente hice la prueba de cambiar a este idioma mucho antes de probar el otro).

Con el parche aplicado, al fin pude lanzar la actualización y todos fuimos felices.

Mensaje informando a los usuarios de que por fin se ha solucionado el bug.

Conclusión

Este bug ha sido probablemente uno de los que más dolores de cabeza me ha dado. Dos de los factores más influyentes era que no estaba familiarizado con el desarrollo nativo de apps. El segundo factor era que el propio error generado era confuso clarificaba más bien poco. De hecho, no tengo muy claro si el culpable fui yoque por desconocimiento no comprobé el charset, o si por el contrario el causante fue android/sugarORM por no contemplar este caso.

Si estás empezando a desarrollar una App, no te recomiendo ningún ORM para almacenar datos persistentes. Android lanzó uno propio (ROOM). Probablemente si lo hubiera sabido desde un inicio, no hubiera tenido este problema.

Y por último, si alguna vez te encuentras perdido y sin salida intentando encontrar el bug en tu programa, pregúntale si habla Ruso.


Update

Después de publicar este post en Hacker New obtuve mucho feedback y gracias a la comunidad pude profundizar más en el tema para darme cuenta del error garrafal que cometí a la hora de gestionar los idiomas y que contribuyó a este problema.

Utillizé getDisplayLanguage() en vez de getLanguage(). El primero devuelve el texto del idioma. El segundo, el código universal del idioma que es el que se debe de utilizar en estos casos. Vaya, que “era un milagro que esto funcionara”.

Como mencioné, no soy desarrollador de Android y por las prisas y el cansancio me conformé con lo primero que parecía funcionar sin reparar demasiado en buenas prácticas o si realmente estaba obteniendo el código del idioma o no. Además, el snipped mostrado es una simplificación de un sistema más complejo que involucra otros servicios, manejo de persistencia y demás, que hacía que no fuera tan obvio. Gracias!