¿Cuántas veces hemos hecho un registro en un sistema que nos ha pedido confirmar el número de teléfono mediante el envío de una OTP (One Time Password)? ¿O se nos ha mandado un código para securizar con más ahínco una operación sensible como una transferencia? ¿Y cuántas veces el móvil de contacto era el mismo desde el que estabais realizando el proceso? Es muy engorroso ver llegar tu notificación de SMS y tener que minimizar la aplicación que la requería, mirar la bandeja de entrada y copiar el fragmento de SMS con el código para continuar el proceso en la app.

De hecho, molaría que se rellenase automáticamente en tal caso… Vale, pues eso lleva existiendo MUCHO tiempo, no traigo nada nuevo al respecto, siento la expectación generada, porque seguro que a la vista del título hay gente pensando: “Pero si la mayoría (por no decir todas) de mis aplicaciones que requieren este flujo ya lo hacen…”. Pero… ¿cuántas veces levantaste la ceja sospechando para qué quería cierta aplicación permiso para leer tu bandeja de entrada de mensajes?

Google es consciente de que ese permiso generaba tanto desconfianza como oportunidades para que un hacker pudiese violar la privacidad del usuario, leyendo cualquier mensaje que el usuario tuviese en la bandeja de entrada y no solo los referentes a la aplicación que pedía los permisos. De hecho, de un tiempo a esta parte, es bastante común que cuando te llegue un mensaje de confirmación no se “lea” automáticamente, ahorrando el proceso de copiarlo y pegarlo al usuario. Tanto es así, que en las últimas versiones del sistema operativo la notificación de un nuevo SMS suele llevar una acción “Copiar código” en caso de que Android detecte que puede ser un código de seguridad.

Así que este tutorial no es tanto para aprender a leer los mensajes que tienen como objetivo cierta aplicación, sino para hacerlo de forma correcta, segura y sin necesidad de obtener permisos del usuario para tal fin.

Prerrequisitos y configuración

  1. El SMS enviado debe seguir un formato obligatorio que consiste en:
<#> Your ExampleApp code is: 123ABC78
FA+9qCX9VSu
  1. Un dispositivo Android con Google Play Services 10.2.X o superior.
  2. Arquitectura y librerías del proyecto actualizadas a AndroidX (no puede haber librerías de support).
  3. Un servidor con conocimiento del número de teléfono del dispositivo al que queremos enviar el SMS.

Una vez cumplidos los prerrequisitos, podemos ir viendo la configuración e implementación de la librería. En primer lugar, debemos importar en nuestro fichero de gradle la última versión compatible con nuestro proyecto de la librería:

implementation 'com.google.android.gms:play-services-auth-api-phone:11.6.0'

Esta librería incluye los componentes necesarios para llevar a cabo las funcionalidades expuestas en este artículo, aunque si queremos otras como pueden ser el selector de número de teléfono, utilizaremos su versión completa:

implementation "com.google.android.gms:play-services-auth:16.0.1"

Ahora ya tenemos el código preparado para importar y utilizar las clases necesarias para la lectura de SMS, como por ejemplo SmsRetriever. Lo único que debemos hacer en este paso es registrar el BroadcastReceiver en el manifiesto de la aplicación necesario para que pueda “escuchar” los eventos de SMS. Lo llamaremos SMSBroadcastReceiver:

<receiver android:name=".SmsBroadcastReceiver" android:exported="true">
    <intent-filter>
        <action android:name="com.google.android.gms.auth.api.phone.SMS_RETRIEVED"/>
    </intent-filter>
</receiver>

Implementación

En primer lugar, debemos informar al servidor del número de teléfono del dispositivo. El método utilizado para este fin es completamente independiente de la implementación del sistema de lectura automática de SMS. Como aliciente, Android ofrece métodos para informar el número de teléfono actual (incluso seleccionar en caso de que el teléfono tenga múltiples líneas) mediante el uso de la clase HintRequest de la librería completa.

Desde el punto de vista de la aplicación, empezaremos por registrar el BroadcastReceiver encargado de la lectura del mensaje e inicializar la clase SmsRetrieverClient dentro de la sección donde queremos efectuar dicha lectura, así como monitorizar los dos métodos de resultado posibles para el éxito de esta inicialización o su fallo. Una vez inicializada la clase, esperará la llegada de un SMS durante los próximos 5 minutos, momento en el que automáticamente cesará su actividad, siendo responsabilidad del programador controlar dichas desconexiones y estar pendiente de cuándo es necesario reactivar el servicio.

smsReceiver = SMSBroadcastReceiver()
smsReceiver.initOTPListener(this)

val intentFilter = IntentFilter()
intentFilter.addAction(SmsRetriever.SMS_RETRIEVED_ACTION)
baseActivity.registerReceiver(smsReceiver, intentFilter)
val client = SmsRetriever.getClient(activity)
val task = client.startSmsRetriever()
task.addOnSuccessListener {
    // API iniciada con éxito, esperando Intent desde el broadcasat
    // Solicitar SMS
}

task.addOnFailureListener {
    // Error al inicializar, comprobar la traza de error
}

En el momento en el que addOnSuccessListener se active debemos solicitar al servidor el envío de la OTP. En caso de error, debemos analizar las trazas para intentar solventarlo y volver a solicitar el envío de OTP.

Por último, y como pieza central de todo este desarrollo, tendremos la clase SmsBroadcastReceiver, la cual ya registramos anteriormente en el Manifest y que será la encargada de leer el cuerpo del SMS una vez SmsRetrieverClient lo identifique como dirigido a nuestra app (gracias al código Hash de 11 dígitos presente en el mensaje). Una vez recibido el SMS, mostramos un ejemplo del tratamiento del texto del mismo y su acceso:

class SMSBroadcastReceiver : BroadcastReceiver() {

private var otpReceiver: OTPReceiveListener? = null

    fun initOTPListener(receiver: OTPReceiveListener) {

        this.otpReceiver = receiver
    }

    override fun onReceive(context: Context, intent: Intent) {
        if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
            val extras = intent.extras
            val status = extras!!.get(SmsRetriever.EXTRA_STATUS) as Status

            when (status.statusCode) {
                CommonStatusCodes.SUCCESS -> {
                    // Obtener el contenido del SMS
                    var otp: String = extras.get(SmsRetriever.EXTRA_SMS_MESSAGE) as String
                    Log.d("OTP_Message", otp)
                    // Extraer el código del cuerpo del mensaje (diferente tratamiento)
                    // En este caso se propaga la OTP a la clase que implemente 
                    // OTPReceivedListener
                    if (otpReceiver != null) {
                        otp = otp.replace("<#> ", "").split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0]
                        otpReceiver!!.onOTPReceived(otp)
                    }
                }

                CommonStatusCodes.TIMEOUT ->
                    // Waiting for SMS timed out (5 minutes)
                    // Handle the error ...
                    if (otpReceiver != null)
                        otpReceiver!!.onOTPTimeOut()
            }
        }
    }

    interface OTPReceiveListener {

        fun onOTPReceived(otp: String)

        fun onOTPTimeOut()
    }
}

Y eso es todo! Ahora cada vez que llegue un mensaje con el formato correcto y el identificador de la app será recogido de forma automática y propagado a la clase correspondiente mediante la interfaz declarada en la clase SmsBroadcastReceiver.

Obtener clave de firma Hash

El mayor problema de toda esta implementación puede venir a la hora de obtener el código HASH de 11 dígitos que ha de figurar al final del cuerpo del SMS obligatoriamente. En la mayoría de los casos, el desarrollador tendrá acceso a los keystores de firma, tanto para producción como para debug, por lo que existen dos formas principales de obtenerlo como mostraremos a continuación. Antes de comenzar, recordemos que este proceso solo es necesario ejecutarlo una vez, por lo que cualquier código añadido a la aplicación para su obtención ha de eliminarse una vez tengamos el valor para las distintas configuraciones de la app.

Vamos a ver la forma más inmediata de obtener dicho valor mediante una clase que ejecutaremos una única vez.

Desde la app: clase SignatureHelper

Vamos a crear una clase que en el momento de ejecutar la app nos informará por la línea de log de los valores de las claves hash necesarios para cada variante definida del proyecto. En primer lugar, para poder ejecutar el código deseado, importaremos temporalmente la siguiente librería en nuestro archivo gradle:

implementation 'com.google.android.gms:play-services-base:11.6.0'

Una vez importada la librería, crearemos una clase con el siguiente código:

public class AppSignatureHelper  extends ContextWrapper {
    public static final String TAG = AppSignatureHelper.class.getSimpleName();

    private static final String HASH_TYPE = "SHA-256";
    public static final int NUM_HASHED_BYTES = 9;
    public static final int NUM_BASE64_CHAR = 11;

    public AppSignatureHelper(Context context) {
        super(context);
    }

    /**
     * Get all the app signatures for the current package
     *
     * @return
     */
    public ArrayList<String> getAppSignatures() {
        ArrayList<String> appCodes = new ArrayList<>();

        try {
            // Get all package signatures for the current package
            String packageName = getPackageName();
            PackageManager packageManager = getPackageManager();
            Signature[] signatures = packageManager.getPackageInfo(packageName,
                    PackageManager.GET_SIGNATURES).signatures;

            // For each signature create a compatible hash
            for (Signature signature : signatures) {
                String hash = hash(packageName, signature.toCharsString());
                if (hash != null) {
                    appCodes.add(String.format("%s", hash));
                }
            }
        } catch (PackageManager.NameNotFoundException e) {
            Log.v(TAG, "Unable to find package to obtain hash.", e);
        }
        return appCodes;
    }

    private static String hash(String packageName, String signature) {
        String appInfo = packageName + " " + signature;
        try {
            MessageDigest messageDigest = MessageDigest.getInstance(HASH_TYPE);
            messageDigest.update(appInfo.getBytes(StandardCharsets.UTF_8));
            byte[] hashSignature = messageDigest.digest();

            // truncated into NUM_HASHED_BYTES
            hashSignature = Arrays.copyOfRange(hashSignature, 0, NUM_HASHED_BYTES);
            // encode into Base64
            String base64Hash = Base64.encodeToString(hashSignature, Base64.NO_PADDING | Base64.NO_WRAP);
            base64Hash = base64Hash.substring(0, NUM_BASE64_CHAR);

            Log.v(TAG + "sms_sample_test", String.format("pkg: %s -- hash: %s", packageName, base64Hash));
            return base64Hash;
        } catch (NoSuchAlgorithmException e) {
            Log.v(TAG+ "sms_sample_test", "hash:NoSuchAlgorithm", e);
        }
        return null;
    }
}

En algún momento de la app llamaremos a las siguientes líneas de código para que nos devuelva por el log las mencionadas claves hash (un buen sitio es la clase Application o en caso de haberla, una Splash):

var appSignature = AppSignatureHelper(this)

appSignature.appSignatures

MUY IMPORTANTE: Una vez ejecutada esta clase, obtendremos por el LogCat los valores deseados y procederemos a eliminar tanto la clase AppSignatureHelper, como su invocación y dependencia, ya que no tienen ninguna funcionalidad más en el proyecto.

Conclusión

Con esto hemos conseguido que nuestra aplicación cace “al vuelo” los SMS de confirmación (OTPs). No es nada complicado, aunque existe un “tricky case” a la hora de obtener la clave de firma, y esto es si permitiste que Google gestionase la clave de release de tu aplicación, ya que no podremos obtener su hash mediante código ya que el keystore deseado no se encuentra a nuestro alcance… Si quieres saber más (esto afecta a otras funcionalidades que dependen de nuestra firma de APK en producción) tendréis que esperar a un próximo artículo en el que trataré este tema, ya que “esta es otra historia y debe ser contada en otra ocasión”.

Referencias

Sms Retriever API Overview

Sms Retriever API Android

App Signature Helper

Sms Retriever Server

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete