๐ Topics Covered in Phase 6
1 Android MQTT client setup
2 User login + device mapping flow
3 Real-time relay control from app
4 Full integration testing: device + dashboard + app
โ๏ธ Step 1: Android Project Setup
๐ก
Required Tools:
โข Android Studio (latest version)
โข Kotlin knowledge basics
โข Gradle 8.x
โข Minimum SDK: 24 (Android 7.0)
โข Target SDK: 34 (Android 14)
build.gradle (Module)
plugins {
id('com.android.application' )
id('org.jetbrains.kotlin.android' )
}
android {
namespace = "com.example.iotmqtt"
compileSdk = 34
defaultConfig {
applicationId = "com.example.iotmqtt"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt" ),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0" )
implementation("androidx.appcompat:appcompat:1.6.1" )
implementation("com.google.android.material:material:1.11.0" )
implementation("androidx.constraintlayout:constraintlayout:2.1.4" )
implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5" )
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" )
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" )
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" )
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6" )
implementation("androidx.navigation:navigation-ui-ktx:2.7.6" )
}
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android ="http://schemas.android.com/apk/res/android" >
<uses-permission android:name ="android.permission.INTERNET" />
<uses-permission android:name ="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name ="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name ="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name ="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup ="true"
android:icon ="@mipmap/ic_launcher"
android:label ="@string/app_name"
android:usesCleartextTraffic ="true"
android:theme ="@style/Theme.IoTMQTT" >
<activity
android:name =".MainActivity"
android:exported ="true" >
<intent-filter>
<action android:name ="android.intent.action.MAIN" />
<category android:name ="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
๐ Step 2: MQTT Service Class
package com.example.iotmqtt
import android.content.Context
import org.eclipse.paho.client.mqttv3.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.*
class MqttService (private val context: Context) {
private var mqttClient: MqttAsyncClient? = null
private val serverUri = "tcp://broker.example.com:1883"
private val clientId = "android-" + UUID.randomUUID().toString ()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _messages = MutableStateFlow<Map<String, String>>(emptyMap())
val messages: StateFlow<Map<String, String>> = _messages
var onMessageReceived: ((topic: String, payload: String) -> Unit)? = null
fun connect () {
try {
_connectionState.value = ConnectionState.CONNECTING
val options = MqttConnectOptions().apply {
isCleanSession = true
connectionTimeout = 30
keepAliveInterval = 60
isAutomaticReconnect = true
}
mqttClient = MqttAsyncClient(serverUri, clientId, null ).apply {
setCallback (object : MqttCallback {
override fun connectionLost (cause: Throwable?) {
_connectionState.value = ConnectionState.DISCONNECTED
cause?.printStackTrace ()
}
override fun messageArrived (topic: String, message: MqttMessage) {
val payload = String(message.payload ())
handleMessage (topic, payload)
}
override fun deliveryComplete (token: IMqttDeliveryToken?) {
}
})
connect (options, null , object : IMqttActionListener {
override fun onSuccess (asyncActionToken: IMqttAsyncToken?) {
_connectionState.value = ConnectionState.CONNECTED
subscribeToTopics ()
}
override fun onFailure (asyncActionToken: IMqttAsyncToken?, exception: Throwable?) {
_connectionState.value = ConnectionState.ERROR
exception?.printStackTrace ()
}
})
}
} catch (e: MqttException) {
_connectionState.value = ConnectionState.ERROR
e.printStackTrace ()
}
}
fun disconnect () {
mqttClient?.disconnect ()
_connectionState.value = ConnectionState.DISCONNECTED
}
private fun subscribeToTopics () {
val topics = listOf (
"home/relay/+/status" ,
"home/relay/+/telemetry" ,
"home/relay/+/response"
)
topics.forEach { topic ->
mqttClient?.subscribe (topic, 1 , null , object : IMqttActionListener {
override fun onSuccess (asyncActionToken: IMqttAsyncToken?) {
android.util.Log.d ("MQTT" , "Subscribed to: $topic" )
}
override fun onFailure (asyncActionToken: IMqttAsyncToken?, exception: Throwable?) {
android.util.Log.e ("MQTT" , "Subscribe failed: $topic" )
}
})
}
}
fun publish (topic: String, payload: String, qos: Int = 1 ) {
try {
val message = MqttMessage(payload.toByteArray ()).apply {
this.qos = qos
}
mqttClient?.publish (topic, message)
} catch (e: MqttException) {
e.printStackTrace ()
}
}
private fun handleMessage (topic: String, payload: String) {
_messages.value = _messages.value.toMutableMap ().apply {
put (topic, payload)
}
onMessageReceived?.invoke (topic, payload)
}
fun turnOn (deviceId: String) {
publish ("home/relay/ $deviceId/command" , "ON" )
}
fun turnOff (deviceId: String) {
publish ("home/relay/ $deviceId/command" , "OFF" )
}
fun toggle (deviceId: String) {
publish ("home/relay/ $deviceId/command" , "TOGGLE" )
}
fun requestStatus (deviceId: String) {
publish ("home/relay/ $deviceId/command" , "STATUS" )
}
}
enum class ConnectionState {
CONNECTED,
CONNECTING,
DISCONNECTED,
ERROR
}
๐ฑ Step 3: ViewModel for Device State
package com.example.iotmqtt
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainViewModel (application: Application) : AndroidViewModel(application) {
private val mqttService = MqttService(application)
val connectionState = mqttService.connectionState
val messages = mqttService.messages
private val _devices = MutableStateFlow<List<Device>>(
listOf (
Device("esp32-001" , "Living Room Light" , "Living Room" , false , false ),
Device("esp32-002" , "Bedroom Fan" , "Bedroom" , false , false ),
Device("esp32-003" , "Kitchen Exhaust" , "Kitchen" , false , false )
)
)
val devices: StateFlow<List<Device>> = _devices
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
val logs: StateFlow<List<LogEntry>> = _logs
init {
mqttService.onMessageReceived = this ::handleMessage
}
fun connect () {
viewModelScope.launch {
addLog ("System" , "Connecting to broker..." )
mqttService.connect ()
}
}
fun disconnect () {
viewModelScope.launch {
mqttService.disconnect ()
addLog ("System" , "Disconnected" )
}
}
fun toggleDevice (deviceId: String) {
viewModelScope.launch {
mqttService.toggle (deviceId)
addLog ("Command" , "Toggle sent to $deviceId" )
}
}
fun turnOn (deviceId: String) {
viewModelScope.launch {
mqttService.turnOn (deviceId)
addLog ("Command" , "ON sent to $deviceId" )
}
}
fun turnOff (deviceId: String) {
viewModelScope.launch {
mqttService.turnOff (deviceId)
addLog ("Command" , "OFF sent to $deviceId" )
}
}
private fun handleMessage (topic: String, payload: String) {
viewModelScope.launch {
addLog (topic, payload)
val parts = topic.split ("/" )
if (parts.size >= 4 ) {
val deviceId = parts[2 ]
_devices.value = _devices.value.map { device ->
if (device.id == deviceId) {
when {
topic.contains ("/status" ) -> {
try {
val json = org.json.JSONObject(payload)
device.copy (
isOnline = json.optString ("state" ) == "online" ,
isOn = json.optString ("relay" ) == "on"
)
} catch (e: Exception) {
device
}
}
else -> device
}
} else device
}
}
}
}
private fun addLog (topic: String, message: String) {
val entry = LogEntry(
timestamp = System.currentTimeMillis (),
topic = topic,
message = message
)
_logs.value = listOf (entry) + _logs.value.take (49 )
}
override fun onCleared () {
super .onCleared ()
mqttService.disconnect ()
}
}
data class Device (
val id: String,
val name: String,
val location: String,
val isOn: Boolean,
val isOnline: Boolean
)
data class LogEntry (
val timestamp: Long,
val topic: String,
val message: String
)
๐จ Step 4: MainActivity with UI
package com.example.iotmqtt
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.iotmqtt.databinding.ActivityMainBinding
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val viewModel: MainViewModel by viewModels ()
private val deviceAdapter = DeviceAdapter { device, action ->
when (action) {
"toggle" -> viewModel.toggleDevice (device.id)
"on" -> viewModel.turnOn (device.id)
"off" -> viewModel.turnOff (device.id)
}
}
private val logAdapter = LogAdapter()
override fun onCreate (savedInstanceState: Bundle?) {
super .onCreate (savedInstanceState)
binding = ActivityMainBinding.inflate (layoutInflater)
setContentView (binding.root )
setupUI ()
observeViewModel ()
}
private fun setupUI () {
binding.recyclerDevices.apply {
layoutManager = LinearLayoutManager(this @MainActivity)
adapter = deviceAdapter
}
binding.recyclerLogs.apply {
layoutManager = LinearLayoutManager(this @MainActivity)
adapter = logAdapter
}
binding.btnConnect.setOnClickListener {
if (viewModel.connectionState.value == ConnectionState.CONNECTED) {
viewModel.disconnect ()
} else {
viewModel.connect ()
}
}
}
private fun observeViewModel () {
lifecycleScope.launch {
repeatOnLifecycle (Lifecycle.State.STARTED) {
launch {
viewModel.connectionState.collect { state ->
when (state) {
ConnectionState.CONNECTED -> {
binding.btnConnect.text = "Disconnect"
binding.tvConnectionStatus.text = "๐ข Connected"
}
ConnectionState.CONNECTING -> {
binding.btnConnect.text = "Connecting..."
binding.tvConnectionStatus.text = "๐ก Connecting"
}
ConnectionState.DISCONNECTED -> {
binding.btnConnect.text = "Connect"
binding.tvConnectionStatus.text = "โซ Disconnected"
}
ConnectionState.ERROR -> {
binding.btnConnect.text = "Retry"
binding.tvConnectionStatus.text = "๐ด Error"
}
}
}
}
launch {
viewModel.devices.collect { devices ->
deviceAdapter.submitList (devices)
}
}
launch {
viewModel.logs.collect { logs ->
logAdapter.submitList (logs)
}
}
}
}
}
}
๐ก
Additional Files Needed:
โข DeviceAdapter.kt - RecyclerView adapter for device cards
โข LogAdapter.kt - RecyclerView adapter for log entries
โข activity_main.xml - Layout with device list and log view
โข colors.xml, themes.xml - Material Design styling
๐งช Step 5: Full Integration Testing
โ Phase 5: 4G LTE
Final Stage: Security โ