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
StateFlowand 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
packagingexcludes 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.



