IoT Tutorials

Android MQTT Kotlin Client: Connect, Subscribe and Notify

Build an Android app in Kotlin that connects to an MQTT broker over TLS, subscribes to alarm topics, raises system notifications and reconnects correctly as the app moves between foreground and background.

Android phone showing an alarm notification from an android mqtt kotlin app connected to a cloud broker
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

Building an Android MQTT Kotlin client is easy to start and surprisingly easy to get wrong: the connection must survive network changes, respect the app lifecycle and avoid draining the battery. In this tutorial you will build a Kotlin app that connects over TLS with MQTT 5, subscribes to alarm topics, raises Android notifications and reconnects in a lifecycle-aware way. It runs against the public broker broker.connectmq.com first, then a private ConnectMQ broker.

Choosing an Android MQTT Library

The library choice matters more on Android than anywhere else, because many old tutorials point to code that no longer works on modern Android versions.

Library Status for new Android apps Notes
Eclipse Paho Android Service Not recommended Unmaintained; known problems with newer Android versions (background service limits, PendingIntent flags)
Eclipse Paho Java client (mqttv3) Usable Plain Java client; you manage threading and lifecycle yourself; MQTT 3.1.1 only
HiveMQ MQTT Client Recommended here Maintained, MQTT 5 and 3.1.1, async API, built-in automatic reconnect with resubscribe

This guide uses the HiveMQ MQTT Client, a general-purpose Java library that runs on Android. Its documentation on the HiveMQ MQTT Client GitHub page lists the Android-specific setup.

Project Setup

Create a new project in Android Studio with an Empty Activity (Compose). Set minSdk to 26 or higher: it lets you create notification channels without version checks and avoids extra desugaring work for the library's Java 8 APIs.

In app/build.gradle.kts:

android {
    defaultConfig {
        minSdk = 26
    }
    packaging {
        resources {
            // Netty, used internally by the client, ships duplicate metadata files
            excludes += setOf("META-INF/INDEX.LIST", "META-INF/io.netty.versions.properties")
        }
    }
}

dependencies {
    implementation("com.hivemq:hivemq-mqtt-client:1.3.3") // check Maven Central for the latest 1.3.x
    implementation("androidx.lifecycle:lifecycle-process:2.8.7")
}

Without the packaging excludes, the build typically fails with a "more than one file was found" error. In AndroidManifest.xml, add the permissions:

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

POST_NOTIFICATIONS is a runtime permission on Android 13 and later; you will request it from the activity.

The Android MQTT Kotlin Client Class

Create MqttRepository.kt. It owns a single client for the whole app, exposes connection state and the latest alarm as StateFlow, and posts a notification for every alarm message.

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) {
        // Runs on the client's network thread: no UI work here, only thread-safe calls
        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()
    }
}

Points that save debugging time:

  • One client per app. Creating a client per screen leads to duplicate connections with the same client ID, which kick each other off the broker in a loop.
  • A stable client ID stored in SharedPreferences, generated once per install.
  • Automatic reconnect with backoff handles Wi-Fi to mobile handovers and tunnels. The HiveMQ client resubscribes automatically if the broker no longer has the session, so you do not lose your subscription after a reconnect.
  • The message callback runs off the main thread. Updating a StateFlow and posting a notification are safe there; touching views is not.
  • A user-initiated disconnect() does not trigger automatic reconnect, which is exactly what you want when the app goes to the background.

Lifecycle-Aware Connection

Holding an MQTT socket open while the app is invisible drains the battery and is cut off by the system anyway. Tie the connection to the app's visible lifecycle with ProcessLifecycleOwner, which reports when the whole app (not one activity) moves between foreground and background.

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()
        })
    }
}

Register it with android:name=".AlarmApp" on the <application> element. Rotation and switching between your own activities no longer disconnect, because the process-level lifecycle only stops when the app as a whole leaves the screen.

The UI and Notification Permission

A minimal Compose activity that asks for notification permission and shows state:

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)
                }
            }
        }
    }
}

Test It with a Simulated Device

Run the app, allow notifications, then from a terminal publish an alarm as a device would:

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"

A heads-up notification appears and the screen shows the alarm. Toggle airplane mode for a few seconds: the status flips to "Disconnected", then back once the network returns, and new alarms arrive without restarting the app. Use your own topic prefix instead of a7k2, and remember the public area is readable by everyone. More CLI tricks are in mosquitto_pub and mosquitto_sub examples.

Designing the Alarm Topics

Notifications are only as good as the messages behind them. Keep alarm payloads short and human-readable, or send compact JSON with a severity field and build the text in the app. Publish alarms with QoS 1 so a brief network hiccup does not swallow them, and avoid the retain flag for one-off events: a retained alarm would fire a fresh notification every time the app reconnects. If you need to show current alarm state on screen, publish that separately on a retained topic such as .../pump3/alarm/active, and keep the notification topic for transitions only.

Background Alerts: Choose the Right Architecture

Because the app disconnects in the background, it only notifies while open. For alerts while the app is closed, pick one of these:

Approach How it works Trade-off
Server-side subscriber plus push (recommended) A backend service subscribes to alarm topics and sends push notifications Most battery-friendly and reliable; needs a small backend
Foreground service The app keeps the MQTT connection in a service with a visible ongoing notification Works without a backend, but newer Android versions restrict foreground service types and durations, and users see a permanent notification
Periodic WorkManager check Connect briefly on a schedule and read retained alarm state Simple, but delayed by minutes and subject to battery optimizations

For a .NET backend, the ASP.NET Core MQTT background service pattern is a good fit for the server-side subscriber. Cross-platform alternatives to native Kotlin are covered in Flutter MQTT and React Native MQTT.

Troubleshooting

  • Build error about duplicate META-INF files: add the packaging excludes shown above.
  • SSLHandshakeException: wrong device clock, or a very old Android trust store; the broker uses a Let's Encrypt certificate.
  • Connects then disconnects in a loop: another client (often an emulator and a phone) uses the same client ID.
  • No notification on Android 13+: the user denied POST_NOTIFICATIONS, or the channel was muted in system settings.
  • Not authorized on a private broker: the client ID must be the one issued with your credentials.

Production Setup with a Private Broker

For real devices and users, connect to a private, authenticated broker. ConnectMQ generates a username, password and client ID when you create a broker, and binds the client ID to those credentials. Swap the builder lines:

MqttClient.builder()
    .useMqttVersion5()
    .identifier("YOUR_CLIENT_ID")
    .serverHost("YOUR_BROKER_HOST")
    .serverPort(8883)
    .sslWithDefaultConfig()
    .simpleAuth()
        .username("YOUR_USERNAME")
        .password("YOUR_PASSWORD".toByteArray())
        .applySimpleAuth()
    // ... reconnect and listeners as before
    .buildAsync()

Do not hard-code a shared credential into a published APK; provision per-user or per-install credentials from your backend and store them with the Android Keystore. MQTT security best practices covers this in depth. The free public MQTT broker is the right place for prototyping, and a hosted MQTT broker is the right place for production traffic. For library options on other platforms, see the MQTT client libraries guide.

Get Started with ConnectMQ

Create your private broker at app.connectmq.com on the free Serverless plan, no credit card required. Plug the generated credentials into the builder, then use the portal's in-browser MQTT client to publish test alarms to your phone and its threshold alert rules and alerts log to keep an eye on device values.

Frequently Asked Questions

Is Eclipse Paho still a good choice for Android?

The Paho Android Service is unmaintained and has issues on recent Android versions, so avoid it for new apps. The plain Paho Java client works but supports only MQTT 3.1.1; the HiveMQ MQTT Client is a more complete option for Kotlin.

Can an Android app keep an MQTT connection open in the background?

Only with a foreground service and a visible notification, and newer Android versions restrict this further. For background alerts, a server-side subscriber that sends push notifications is more reliable.

Does the HiveMQ MQTT Client support Kotlin coroutines?

It exposes async (CompletableFuture), reactive and blocking APIs. You can call the async API from coroutines with the kotlinx-coroutines-jdk8 await() extension.

Which port should an Android MQTT client use?

Use TLS on port 8883. If a network only allows HTTPS, use secure WebSocket on port 8084 with path /mqtt, which the HiveMQ client supports through its WebSocket configuration.

Related articles

Best MQTT CLI Tools for Testing, Scripting and Debugging

A hands-on comparison of mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, the MQTT.js CLI, curl and mqttui, with the same test run against broker.connectmq.com so you can choose the right tool for scripting and debugging.

8 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.