Tutoriales de IoT

Cliente Android MQTT en Kotlin: conecta, suscríbete y notifica

Crea una aplicación Android en Kotlin que se conecta a un broker MQTT por TLS, se suscribe a topics de alarma, lanza notificaciones del sistema y se reconecta correctamente cuando la aplicación pasa entre primer y segundo plano.

Teléfono Android que muestra una notificación de alarma de una aplicación Android MQTT en Kotlin conectada a un broker en la nube
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Crear un cliente Android MQTT en Kotlin es fácil de empezar y sorprendentemente fácil de hacer mal: la conexión debe sobrevivir a los cambios de red, respetar el ciclo de vida de la aplicación y no agotar la batería. En este tutorial crearás una aplicación en Kotlin que se conecta por TLS con MQTT 5, se suscribe a topics de alarma, lanza notificaciones de Android y se reconecta teniendo en cuenta el ciclo de vida. Primero se ejecuta contra el servidor MQTT público gratuito broker.connectmq.com, pensado solo para pruebas y prototipos, y después contra un broker privado de ConnectMQ.

Elegir una biblioteca MQTT para Android

La elección de la biblioteca importa más en Android que en ningún otro sitio, porque muchos tutoriales antiguos remiten a código que ya no funciona en las versiones modernas de Android.

Biblioteca Estado para nuevas apps Android Notas
Eclipse Paho Android Service No recomendada Sin mantenimiento; problemas conocidos con versiones recientes de Android (límites de servicios en segundo plano, flags de PendingIntent)
Cliente Java de Eclipse Paho (mqttv3) Utilizable Cliente Java simple; tú gestionas los hilos y el ciclo de vida; solo MQTT 3.1.1
HiveMQ MQTT Client La recomendada aquí Mantenida, MQTT 5 y 3.1.1, API asíncrona, reconexión automática integrada con nueva suscripción

Esta guía usa HiveMQ MQTT Client, una biblioteca Java de uso general que funciona en Android. Su documentación en la página de GitHub de HiveMQ MQTT Client detalla la configuración específica para Android.

Configuración del proyecto

Crea un proyecto nuevo en Android Studio con una Empty Activity (Compose). Pon minSdk a 26 o superior: te permite crear canales de notificación sin comprobaciones de versión y evita trabajo adicional de desugaring para las API de Java 8 que usa la biblioteca.

En app/build.gradle.kts:

android {
    defaultConfig {
        minSdk = 26
    }
    packaging {
        resources {
            // Netty, que el cliente usa internamente, incluye archivos de metadatos duplicados
            excludes += setOf("META-INF/INDEX.LIST", "META-INF/io.netty.versions.properties")
        }
    }
}

dependencies {
    implementation("com.hivemq:hivemq-mqtt-client:1.3.3") // consulta Maven Central para la última 1.3.x
    implementation("androidx.lifecycle:lifecycle-process:2.8.7")
}

Sin las exclusiones de packaging, la compilación suele fallar con un error del tipo «more than one file was found». En AndroidManifest.xml, añade los permisos:

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

POST_NOTIFICATIONS es un permiso en tiempo de ejecución en Android 13 y posteriores; lo solicitarás desde la actividad.

La clase del cliente Android MQTT en Kotlin

Crea MqttRepository.kt. Posee un único cliente para toda la aplicación, expone el estado de la conexión y la última alarma como StateFlow y publica una notificación por cada mensaje de alarma.

package com.example.mqttalarms

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.hivemq.client.mqtt.MqttClient
import com.hivemq.client.mqtt.datatypes.MqttQos
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class MqttRepository(private val context: Context) {

    companion object {
        const val PREFIX = "connectmq/demo/a7k2"
        private const val CHANNEL_ID = "alarms"
        private const val TAG = "MqttRepository"
    }

    private val _connected = MutableStateFlow(false)
    val connected: StateFlow<Boolean> = _connected

    private val _lastAlarm = MutableStateFlow("No alarms yet")
    val lastAlarm: StateFlow<String> = _lastAlarm

    private val notificationId = AtomicInteger(1000)

    private val clientId: String by lazy {
        val prefs = context.getSharedPreferences("mqtt", Context.MODE_PRIVATE)
        prefs.getString("client_id", null) ?: ("android-" + UUID.randomUUID().toString().take(12))
            .also { prefs.edit().putString("client_id", it).apply() }
    }

    private val client: Mqtt5AsyncClient by lazy {
        MqttClient.builder()
            .useMqttVersion5()
            .identifier(clientId)
            .serverHost("broker.connectmq.com")
            .serverPort(8883)
            .sslWithDefaultConfig()
            .automaticReconnect()
                .initialDelay(1, TimeUnit.SECONDS)
                .maxDelay(30, TimeUnit.SECONDS)
                .applyAutomaticReconnect()
            .addConnectedListener { _connected.value = true; Log.i(TAG, "connected") }
            .addDisconnectedListener { ctx ->
                _connected.value = false
                Log.w(TAG, "disconnected: ${ctx.cause.message} (source ${ctx.source})")
            }
            .buildAsync()
    }

    init {
        val channel = NotificationChannel(CHANNEL_ID, "Device alarms",
            NotificationManager.IMPORTANCE_HIGH)
        context.getSystemService(NotificationManager::class.java)
            .createNotificationChannel(channel)
    }

    fun connect() {
        if (client.state.isConnectedOrReconnect) return
        client.connectWith()
            .cleanStart(true)
            .keepAlive(30)
            .send()
            .whenComplete { connAck, error ->
                if (error != null) {
                    Log.e(TAG, "connect failed", error)
                } else {
                    Log.i(TAG, "CONNACK ${connAck.reasonCode}")
                    subscribe()
                }
            }
    }

    private fun subscribe() {
        client.subscribeWith()
            .topicFilter("$PREFIX/+/alarm")
            .qos(MqttQos.AT_LEAST_ONCE)
            .callback(::onAlarm)
            .send()
            .whenComplete { subAck, error ->
                if (error != null) Log.e(TAG, "subscribe failed", error)
                else Log.i(TAG, "SUBACK ${subAck.reasonCodes}")
            }
    }

    private fun onAlarm(publish: Mqtt5Publish) {
        // Se ejecuta en el hilo de red del cliente: nada de trabajo de UI aquí, solo llamadas thread-safe
        val device = publish.topic.levels.getOrNull(3) ?: "unknown"
        val text = String(publish.payloadAsBytes, Charsets.UTF_8)
        _lastAlarm.value = "$device: $text"
        showNotification(device, text)
    }

    private fun showNotification(device: String, text: String) {
        val nm = NotificationManagerCompat.from(context)
        if (!nm.areNotificationsEnabled()) return
        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(android.R.drawable.stat_sys_warning)
            .setContentTitle("Alarm on $device")
            .setContentText(text)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .build()
        try {
            nm.notify(notificationId.incrementAndGet(), notification)
        } catch (e: SecurityException) {
            Log.w(TAG, "notification permission missing")
        }
    }

    fun disconnect() {
        if (client.state.isConnected) client.disconnect()
    }
}

Puntos que ahorran tiempo de depuración:

  • Un cliente por aplicación. Crear un cliente por pantalla provoca conexiones duplicadas con el mismo client ID, que se expulsan mutuamente del broker en bucle.
  • Un client ID estable guardado en SharedPreferences y generado una sola vez por instalación.
  • La reconexión automática con backoff gestiona los cambios de Wi-Fi a datos móviles y los túneles. El cliente de HiveMQ vuelve a suscribirse automáticamente si el broker ya no tiene la sesión, así que no pierdes la suscripción tras una reconexión.
  • El callback de mensajes se ejecuta fuera del hilo principal. Actualizar un StateFlow y publicar una notificación es seguro ahí; tocar las vistas no lo es.
  • Un disconnect() iniciado por el usuario no activa la reconexión automática, que es exactamente lo que quieres cuando la aplicación pasa a segundo plano.

Conexión consciente del ciclo de vida

Mantener abierto un socket MQTT mientras la aplicación no es visible agota la batería y, de todos modos, el sistema lo corta. Vincula la conexión al ciclo de vida visible de la aplicación con ProcessLifecycleOwner, que informa de cuándo la aplicación entera (no una actividad) pasa entre primer y segundo plano.

package com.example.mqttalarms

import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner

class AlarmApp : Application() {
    lateinit var mqtt: MqttRepository
        private set

    override fun onCreate() {
        super.onCreate()
        mqtt = MqttRepository(this)
        ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onStart(owner: LifecycleOwner) = mqtt.connect()
            override fun onStop(owner: LifecycleOwner) = mqtt.disconnect()
        })
    }
}

Regístrala con android:name=".AlarmApp" en el elemento <application>. Girar la pantalla o cambiar entre tus propias actividades ya no desconecta, porque el ciclo de vida a nivel de proceso solo se detiene cuando la aplicación en su conjunto sale de la pantalla.

La interfaz y el permiso de notificaciones

Una actividad Compose mínima que pide el permiso de notificaciones y muestra el estado:

package com.example.mqttalarms

import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    private val requestPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (Build.VERSION.SDK_INT >= 33) {
            requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
        val mqtt = (application as AlarmApp).mqtt
        setContent {
            MaterialTheme {
                val connected by mqtt.connected.collectAsState()
                val alarm by mqtt.lastAlarm.collectAsState()
                Column(Modifier.padding(24.dp)) {
                    Text(if (connected) "Connected to broker" else "Disconnected")
                    Text("Last alarm: $alarm", style = MaterialTheme.typography.titleMedium)
                }
            }
        }
    }
}

Pruébalo con un dispositivo simulado

Ejecuta la aplicación, permite las notificaciones y, desde una terminal, publica una alarma como lo haría un dispositivo:

mosquitto_pub -h broker.connectmq.com -p 1883 -i android-test-a7k2 \
  -t connectmq/demo/a7k2/pump3/alarm -q 1 -m "Pressure above 8 bar"

Aparece una notificación emergente y la pantalla muestra la alarma. Activa el modo avión unos segundos: el estado cambia a «Disconnected» y vuelve en cuanto regresa la red, y las nuevas alarmas llegan sin reiniciar la aplicación. Usa tu propio prefijo de topic en lugar de a7k2 y recuerda que el área pública la puede leer cualquiera. Encontrarás más trucos de CLI en los ejemplos de mosquitto_pub y mosquitto_sub.

Diseño de los topics de alarma

Las notificaciones son tan buenas como los mensajes que las respaldan. Mantén los payloads de alarma cortos y legibles, o envía un JSON compacto con un campo de severidad y construye el texto en la aplicación. Publica las alarmas con QoS 1 para que un breve corte de red no se las trague, y evita el flag de retención en los eventos puntuales: una alarma retenida lanzaría una nueva notificación cada vez que la aplicación se reconecte. Si necesitas mostrar en pantalla el estado actual de las alarmas, publícalo por separado en un topic retenido como .../pump3/alarm/active y reserva el topic de notificaciones solo para las transiciones.

Alertas en segundo plano: elige la arquitectura adecuada

Como la aplicación se desconecta en segundo plano, solo notifica mientras está abierta. Para recibir alertas con la aplicación cerrada, elige una de estas opciones:

Enfoque Cómo funciona Contrapartida
Suscriptor en el servidor más push (recomendado) Un servicio de backend se suscribe a los topics de alarma y envía notificaciones push Lo más respetuoso con la batería y lo más fiable; necesita un pequeño backend
Foreground service La aplicación mantiene la conexión MQTT en un servicio con una notificación permanente visible Funciona sin backend, pero las versiones recientes de Android restringen los tipos y la duración de los foreground services, y los usuarios ven una notificación permanente
Comprobación periódica con WorkManager Conectar brevemente según una programación y leer el estado de alarma retenido Sencillo, pero con minutos de retraso y sujeto a las optimizaciones de batería

Para un backend .NET, el patrón del servicio en segundo plano MQTT con ASP.NET Core encaja bien como suscriptor en el servidor. Las alternativas multiplataforma a Kotlin nativo se tratan en MQTT con Flutter y MQTT con React Native.

Solución de problemas

  • Error de compilación por archivos META-INF duplicados: añade las exclusiones de packaging mostradas arriba.
  • SSLHandshakeException: reloj del dispositivo incorrecto o trust store de Android muy antiguo; el broker usa un certificado de Let's Encrypt.
  • Se conecta y se desconecta en bucle: otro cliente (a menudo un emulador y un teléfono) usa el mismo client ID.
  • No hay notificación en Android 13+: el usuario denegó POST_NOTIFICATIONS o el canal se silenció en los ajustes del sistema.
  • No autorizado en un broker privado: el client ID debe ser el emitido junto con tus credenciales.

Configuración de producción con un broker privado

Para dispositivos y usuarios reales, conéctate a un broker privado y autenticado. ConnectMQ genera un usuario, una contraseña y un client ID al crear un broker, y vincula el client ID a esas credenciales. Cambia las líneas del builder:

MqttClient.builder()
    .useMqttVersion5()
    .identifier("YOUR_CLIENT_ID")
    .serverHost("YOUR_BROKER_HOST")
    .serverPort(8883)
    .sslWithDefaultConfig()
    .simpleAuth()
        .username("YOUR_USERNAME")
        .password("YOUR_PASSWORD".toByteArray())
        .applySimpleAuth()
    // ... reconexión y listeners como antes
    .buildAsync()

No incrustes una credencial compartida en un APK publicado; aprovisiona credenciales por usuario o por instalación desde tu backend y guárdalas con Android Keystore. Las buenas prácticas de seguridad en MQTT lo tratan en profundidad. El servidor MQTT público gratuito es el sitio adecuado para prototipar, y un broker MQTT gestionado es el sitio adecuado para el tráfico de producción. Para opciones de bibliotecas en otras plataformas, consulta la guía de bibliotecas cliente MQTT.

Empieza con ConnectMQ

Crea tu broker privado en app.connectmq.com con el plan Serverless gratuito, sin tarjeta de crédito. Introduce las credenciales generadas en el builder y usa el cliente MQTT en el navegador del portal para publicar alarmas de prueba en tu teléfono, y sus reglas de alerta por umbral y su registro de alertas para vigilar los valores de los dispositivos.

Preguntas frecuentes

¿Sigue siendo Eclipse Paho una buena opción para Android?

Paho Android Service no tiene mantenimiento y da problemas en las versiones recientes de Android, así que evítalo en aplicaciones nuevas. El cliente Java simple de Paho funciona, pero solo admite MQTT 3.1.1; HiveMQ MQTT Client es una opción más completa para Kotlin.

¿Puede una aplicación Android mantener abierta una conexión MQTT en segundo plano?

Solo con un foreground service y una notificación visible, y las versiones recientes de Android lo restringen aún más. Para alertas en segundo plano, un suscriptor en el servidor que envía notificaciones push es más fiable.

¿HiveMQ MQTT Client admite corrutinas de Kotlin?

Ofrece API asíncronas (CompletableFuture), reactivas y bloqueantes. Puedes llamar a la API asíncrona desde corrutinas con la extensión await() de kotlinx-coroutines-jdk8.

¿Qué puerto debe usar un cliente MQTT en Android?

Usa TLS en el puerto 8883. Si una red solo permite HTTPS, usa WebSocket seguro en el puerto 8084 con la ruta /mqtt, que el cliente de HiveMQ admite mediante su configuración de WebSocket.

Artículos relacionados

Las mejores herramientas CLI de MQTT para pruebas, scripts y depuración

Una comparativa práctica de mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, la CLI de MQTT.js, curl y mqttui, con la misma prueba ejecutada contra broker.connectmq.com para que elijas la herramienta adecuada para scripts y depuración.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.