A menudo, cuando necesito un servicio de SMS para algún proyecto, recurro a plataformas como Twilio, que son bastante robustas y confiables. Sin embargo, en ocasiones solo necesito probar ciertos escenarios o enviar alertas, y no quiero incurrir en gastos adicionales.
En mi país, los SMS son gratuitos en todos los planes de telefonía o en la mayoría de ellos, por lo que enviar un mensaje de texto no me cuesta nada si ya tengo un número de teléfono. Además, adquirir uno nuevo no suele superar los 10 dólares en muchos casos.
Para resolver esta necesidad, desarrollé una aplicación en Android utilizando Kotlin. Esta aplicación funciona como un servicio persistente en segundo plano y se conecta a un WebSocket. A través del WebSocket, la aplicación recibe el texto del mensaje y el número al que se enviará el SMS.
También escribí el código del WebSocket de manera sencilla en NodeJS. Esta solución me permite aprovechar los SMS gratuitos incluidos en la mayoría de los planes de telefonía de mi país, sin incurrir en costos adicionales.
Además, tanto la aplicación como el backend pueden soportar múltiples conexiones simultáneas. El código de NodeJS se encarga de gestionar estas conexiones y, de manera aleatoria, selecciona uno de los clientes conectados (la aplicación Android) para enviar el SMS.
Esta estrategia ofrece varias ventajas:
- Distribución de la carga: Al seleccionar aleatoriamente un dispositivo del pool, se distribuye la carga de envío de SMS entre múltiples dispositivos.
- Reducción de riesgos: Al utilizar varios dispositivos para enviar mensajes, se minimiza el riesgo de recibir penalizaciones por spam por parte de la operadora.
- Escalabilidad: La solución puede escalar fácilmente agregando más dispositivos al pool para manejar un mayor volumen de mensajes.
Finalmente, el código del WebSocket en NodeJS es sencillo y eficiente, lo que facilita su mantenimiento y expansión según sea necesario.
Android
La aplicación Android tiene una UI muy sencilla, que incluye:
- Un campo para agregar la URL del WebSocket.
- Un texto que muestra el ID del dispositivo para identificarlo en el backend.
- Dos botones: uno para conectar y otro para desconectar.
La aplicación está desarrollada con Jetpack Compose, por lo que es sencilla de entender a nivel UI. Por lo tanto, solo explicaré las partes más esenciales del código.
Conexión a websocket
La parte central del código es la conexión al servicio de WebSocket para esto use la librería OkHttp
, agregarla es tan simple como poner esto en las dependencias de graddle.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
Hay una clase llamada WebSocketManager
. Esta clase tiene una función llamada start
, que recibe dos parámetros: la URL (dirección y puerto) hacia donde se conectará el WebSocket y un ID. Este ID debe ser único dentro del backend de WebSocket, por lo que se genera un UUID v4 para este fin.
private var client: OkHttpClient = OkHttpClient()
private var webSocket: WebSocket? = null
private var connectionStatus: CompletableFuture? = null
fun start(url: String, id: String): Boolean {
return try {
connectionStatus = CompletableFuture()
val request = Request.Builder().url("ws://$url?id=$id").build()
webSocket = client.newWebSocket(request, MyWebSocketListener(listener))
return connectionStatus!!.get()
} catch (e: Exception) {
false
}
}
Como se puede ver en el código anterior, la conexión es bastante sencilla. Hay dos partes esenciales: la primera es que, para crear el WebSocket y conectarse a él, se necesita enviar al método newWebSocket
un objeto Request
que se crea básicamente con la URL y una implementación de la interfaz WebSocketListener
, que en este caso es mi clase llamada MyWebSocketListener
.
La diferencia aquí radica en que, en el constructor de MyWebSocketListener
, envío otra interfaz que puede ser implementada para propagar como callback el método onMessage
de la interfaz WebSocketListener
de OkHttp.
interface MessageListener {
fun onMessage(message: SmsModel)
}
Esta interface la uso para propagar el menaje que llega dentro del método onMessage
.
private inner class MyWebSocketListener(private val callback: MessageListener)
override fun onMessage(webSocket: WebSocket, text: String) {
try {
println("Message received: $text")
val gson = Gson()
val message = gson.fromJson(text, SmsModel::class.java)
println("Message received: $message")
callback.onMessage(message)
} catch (e: Exception) {
println("Message received: $text")
}
}
Al final del método se llama a callback.onMessage
, al que se le envía un objeto de tipo SmsModel
, que se crea a partir del string de tipo JSON que recibe desde el WebSocket usando la librería Gson
.
implementation("com.google.code.gson:gson:2.11.0")
data class SmsModel(
val number: String,
val text: String
)
Esta interface solo sirve para poder propagar el mensaje y no implementar toda la interface WebSocketListener
en otro lugar.
Como último detalle, tengo un objeto llamado connectionStatus = CompletableFuture()
. Esto lo único que hace es bloquear el hilo actual desde donde se llama a la clase WebSocketManager
para saber si el WebSocket se conectó o no. Debido a que, como pueden ver, la conexión se maneja en una interfaz y callback, esto es un método asincrónico y quería convertirlo en sincrónico para que desde la interfaz se sepa si la conexión se realizó o no.
Servicio persistente
Una vez que se tiene el código para la conexión al backend, lo que necesitamos es crear un servicio en Android que persista en segundo plano para estar recibiendo los mensajes del WebSocket y enviar los SMS. Este servicio debe funcionar tanto si el sistema está en otra app como si está con la pantalla apagada.
Para lograr esto en Android, tenemos que crear un servicio implementando la clase abstracta Service
.
Esta implementación la hice en la clase ForegroundService
, donde la conexión al WebSocket se maneja en el método onStartCommand
. Pero para que el servicio funcione de manera persistente, se necesitan hacer dos cosas:
NotificationChannel
: Es un canal de notificación necesario a partir de Android 8.0 para agrupar y categorizar notificaciones con configuraciones específicas.Notification
: Es el contenido visible de la notificación que ves en la barra de notificaciones y que contiene el mensaje que deseas mostrar al usuario.
Al final, la notificación debe pasarse al método startForeground
junto con un ID de identificación para que el sistema mantenga el servicio de manera persistente y no cierre el proceso.
Para ejecutar este servicio, deberá crearse un Intent. En el código, se hace de la siguiente manera:
viewModel.onIdChange(UUID.randomUUID().toString())
val serviceIntent = Intent(context, ForegroundService::class.java)
serviceIntent.putExtra(context.getString(R.string.extra_url), viewModel.urlText)
serviceIntent.putExtra(context.getString(R.string.extra_id), viewModel.id)
context.startService(serviceIntent)
Lo relevante está en la segunda línea, donde se pasa la referencia de la clase ForegroundService
al Intent. Una vez se tiene el objeto Intent, se envía usando el contexto actual mediante la última línea con el método startService
.
Las otras líneas son datos que se envían al servicio mediante Extras
. Estos datos incluyen el ID y la URL que necesito para que el WebSocket se conecte al servicio. Estos datos se envían al servicio y del servicio al objeto de WebSocket para su conexión.
Envío de mensajes
El envío de mensajes es bastante simple, pero se necesitan algunos permisos en el dispositivo para poder hacerlo. Además, el código que implementé solo funciona para la API 31 en adelante. Esto se debe a que mis snippets de código para esta funcionalidad estaban desfasados y quería implementar métodos más nuevos.
En todo caso, la implementación es realmente sencilla y pueden ver cómo funciona la clase SmsManager aquí.
Mi clase SmsService
tiene un solo método que es sendSms
y eso es todo:
fun sendSms(message: SmsModel) {
if (ContextCompat.checkSelfPermission(
context,
"android.permission.SEND_SMS"
) == PackageManager.PERMISSION_GRANTED
) {
if (message.number.isNotEmpty() && message.text.isNotEmpty()) {
val subscriptionManager =
context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
val subscriptionId =
subscriptionManager.activeSubscriptionInfoList[0].subscriptionId
val smsManager = context.getSystemService(SmsManager::class.java)
.createForSubscriptionId(subscriptionId)
smsManager.sendTextMessage(message.number, null, message.text, null, null)
}
}
}
Las nuevas APIs para el envío de mensajes requieren que se verifiquen los permisos de envío de SMS antes de usarlas. Como pueden ver en el código, esto se realiza antes de generar un envío.
Este método no tiene un mecanismo para saber si el mensaje se envió o no, por lo que, si el envío falla, no se puede saber de ninguna forma actualmente.
Para implementar un mecanismo, hay que crear un Intent de tipo Broadcast. En el futuro, tal vez haga eso, para que en caso de que el SMS no se envíe, se notifique al WebSocket y se intente nuevamente.
WebSocket en NodeJS
No entraré en detalles técnicos sobre el código del WebSocket, ya que es realmente sencillo de implementar en NodeJS y aún más sencillo es el código que escribí.
const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');
const { URL } = require('url');
const getRandomElementFromSet = set => {
const arr = Array.from(set).filter(item => item.id !== 'host');
return arr[Math.floor(Math.random() * arr.length)];
};
const wss = new WebSocket.Server({ port: 8080 });
let clients = new Set();
wss.on('connection', (ws, req) => {
clients.add(ws);
const url = new URL(req.url, `http://${req.headers.host}`);
const deviceId = url.searchParams.get('id');
if (deviceId === null || deviceId === undefined || deviceId === '') {
ws.id = uuidv4();
}
else {
ws.id = deviceId;
}
ws.on('message', message => {
console.log(`Received message ${message} from connection ${ws.id}`);
const messages = JSON.parse(message);
if (!messages) {
console.log('No messages');
return;
}
messages.forEach(m => {
const randomClient = getRandomElementFromSet(clients);
if (!randomClient) {
console.log('No clients connected');
return;
}
if (randomClient.readyState === WebSocket.OPEN) {
const messageToSend = JSON.stringify(m);
randomClient.send(`${messageToSend}`);
console.log(`Sent message ${messageToSend} to connection ${randomClient.id}`);
}
else {
console.log(`Client ${randomClient.id} is not connected`);
clients.delete(randomClient);
}
});
});
ws.on('close', (code, reason) => {
console.log(`Connection ${ws.id} closed. Code: ${code}. Reason: ${reason}`);
clients.delete(ws);
});
console.log(ws.id);
});
Simplemente se genera una escucha para conexiones WebSocket en el puerto 8080. Cuando un cliente nuevo (la app) se conecta al sistema, se obtiene su ID y se asigna ese ID como una propiedad en el objeto de WebSocket. Si no tiene un ID, se genera un UUID al azar y se agrega a un conjunto de clientes.
Hay un ID que se espera que sea llamado host
. Este sería el servidor que enviará los mensajes a los clientes. Por cada mensaje que se envíe, se obtiene un cliente aleatorio y se le envía el mensaje para que la app lo reciba y envíe el SMS.
El host deberá enviar un string de tipo JSON, que deberá ser un array de objetos con dos propiedades, tal como en este ejemplo:
[
{
"number": "123456",
"text": "hola"
}
]
Con esto, nos aseguramos de que se puedan enviar muchos mensajes en una sola llamada y que se envíen desde distintos dispositivos conectados.
Nota: El envío es aleatorio, por lo que la distribución y la probabilidad de que se usen todos los teléfonos conectados de manera homogénea no están aseguradas de ningún modo.
Para asegurar el uso equitativo de los dispositivos, lo mejor sería implementar un mecanismo de planificación como Round-Robin.
Repositorio
El código para la aplicación en Android se puede clonar desde aquí