In this guide
How to Build an Android BLE App That Talks to Your ESP32
Summary: Most student IoT builds stop at the serial monitor — the hardware works, but there is no real way to use it. This guide walks you through building an Android app that connects to your ESP32 over Bluetooth Low Energy (BLE): how GATT services and characteristics work, setting up advertising on the ESP32, requesting runtime Bluetooth permissions on Android 12+, scanning, connecting, reading, writing and subscribing to notifications, plus the reliability patterns (operation queueing, disconnect handling) that separate a demo from something you can actually hand to someone.
The problem: the serial monitor is not a product
A very common student build goes like this: the ESP32 reads a sensor, prints values to the serial monitor, maybe pushes them to a cloud dashboard. Then comes the question every mentor asks: "Can I see this on my phone?"
Wi-Fi is the usual answer — run a small web server on the ESP32 and open it in a browser. That works, but it needs both devices on the same network, and it drains the battery fast. BLE is the better fit for a student project: the ESP32 can advertise and stream data for hours on a small battery, the phone pairs with a tap, and the whole flow feels like a real product instead of a debug session.
The blocker is that BLE on Android has a learning curve. The API is callback-heavy, permissions changed in Android 12, and notifications silently fail if you miss one descriptor write. This guide covers the complete path.
How BLE communication works: the 5 concepts you need
Before writing code, get the mental model right. BLE follows a client–server model called GATT (Generic Attribute Profile).
- Peripheral vs central — Your ESP32 is the peripheral: it advertises itself ("I am here, I offer these services"). Your Android phone is the central: it scans, finds the peripheral, and connects. A peripheral can only talk to one central at a time, but a central can connect to several peripherals.
- Services — A service is a grouping of related data, identified by a 16-bit or 128-bit UUID. Think of it as a folder: "environmental sensing" is a standard service.
- Characteristics — The actual data points, each inside a service, each with its own UUID. A temperature characteristic holds the temperature value. Characteristics have properties: read, write, notify, indicate.
- Descriptors — Small attributes attached to a characteristic that configure it. The important one is the Client Characteristic Configuration Descriptor (CCCD, UUID 0x2902): you write
0x0001to it to turn on notifications. Skip this and your "subscribe" call silently does nothing — the most common BLE bug there is. - MTU — The maximum packet size, 23 bytes by default (20 bytes usable payload). You can negotiate a larger MTU, but design your protocol around small packets: one sensor reading per notification, not a whole JSON document.
BLE vs classic Bluetooth vs Wi-Fi, for your project:
| Option | Power | Pairing | Range | Best for |
|---|---|---|---|---|
| BLE | Very low | Tap-to-connect, no PIN typically | ~10–50 m | Sensor readings, commands, battery builds |
| Classic Bluetooth (SPP) | Medium | Pairing dance | ~10 m | Audio, continuous serial streams |
| Wi-Fi (ESP32 web server) | High | Needs shared network | ~30 m | Dashboards, firmware uploads, video |
If your project streams a sensor value every second and sends occasional commands, BLE wins.
Step 1: Set up the ESP32 peripheral
On the ESP32 side you need two things: start advertising with a service UUID, and create a characteristic with the properties you need. The Arduino BLEDevice library keeps this short:
#include <BLEDevice.h>
#include <BLEServer.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define TEMP_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CMD_CHAR_UUID "2a6e7b1c-9d3e-4a1f-8c2b-7e5d9f0a1b2c"
BLECharacteristic *tempCharacteristic;
class CmdCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) {
String cmd = c->getValue();
// handle command, e.g. "LED_ON" / "LED_OFF"
if (cmd == "LED_ON") digitalWrite(LED_BUILTIN, HIGH);
if (cmd == "LED_OFF") digitalWrite(LED_BUILTIN, LOW);
}
};
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
BLEDevice::init("ProjectechSensor");
BLEServer *server = BLEDevice::createServer();
BLEService *service = server->createService(SERVICE_UUID);
tempCharacteristic = service->createCharacteristic(
TEMP_CHAR_UUID, BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_NOTIFY);
BLECharacteristic *cmd = service->createCharacteristic(
CMD_CHAR_UUID, BLECharacteristic::PROPERTY_WRITE);
cmd->setCallbacks(new CmdCallbacks());
service->start();
BLEDevice::getAdvertising()->addServiceUUID(SERVICE_UUID);
BLEDevice::getAdvertising()->start();
}
void loop() {
float t = 25.0 + random(-50, 50) / 100.0; // replace with real sensor
tempCharacteristic->setValue(String(t, 2).c_str());
tempCharacteristic->notify();
delay(2000);
}
Two design notes that save you pain later:
- Use your own 128-bit UUIDs (generate them once with an online UUID generator) so your app never collides with another student's project in the same lab.
- Keep the notification payload tiny. A 5-character string like
"25.42"fits comfortably in the default 20-byte payload. If you ever need more, negotiate MTU on the Android side instead of fragmenting manually.
Flash this, then verify with the free nRF Connect app on your phone before writing a line of Android code. If nRF Connect can see ProjectechSensor, read the temperature and write to the command characteristic, your ESP32 side is done — every later Android bug is then clearly on the Android side.
Step 2: Android project setup and permissions
Create a normal Kotlin project (minimum SDK 26 is a safe floor for BLE work). BLE lives in android.bluetooth, so there is no extra dependency.
Permissions are where most tutorials are outdated. On Android 12 (API 31) and above:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
On Android 11 and below, BLE scanning is tied to location services instead:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Request them at runtime with the standard permission launcher, and on older Android versions also check that location services are switched on — scanning returns zero results with location off, and the API gives you no error to explain why. This single gotcha accounts for a large share of "scanning doesn't work" complaints.
Step 3: Scanning for your ESP32
Scan with a filter on your service UUID so you find your board instead of every smartwatch in the room:
val scanner = bluetoothAdapter.bluetoothLeScanner
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(UUID.fromString(SERVICE_UUID)))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
scanner.startScan(listOf(filter), settings, scanCallback)
Practical rules:
- Always stop the scan once you find the device (
stopScan), or after ~10 seconds with a timeout. Continuous scanning is the fastest way to drain the phone battery and it keeps the radio busy. - Show a simple device list in your UI — name plus MAC address. In a lab with five identical ESP32s, the MAC is how you pick yours.
- Request a runtime check for Bluetooth being enabled (
ACTION_REQUEST_ENABLE) before scanning; handle the user saying no gracefully instead of crashing.
Step 4: Connect, discover services, read and write
Connection is callback-driven. The skeleton every BLE app shares:
val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
g.discoverServices() // mandatory next step
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
// find your service + characteristics here, then read/subscribe
}
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) {
val text = String(c.value, Charsets.UTF_8)
// post to UI thread: runOnUiThread { tempView.text = text }
}
})
The order is strict: connect → discoverServices → read/write/subscribe. Anything attempted before onServicesDiscovered fires is silently ignored. To enable notifications on the temperature characteristic you must do two writes in order:
gatt.setCharacteristicNotification(tempChar, true)
val cccd = tempChar.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(cccd)
Miss the descriptor write and setCharacteristicNotification(true) alone does nothing on the ESP32 side — this is the bug from the concepts section, now in code form. Writing a command is simpler:
cmdChar.value = "LED_ON".toByteArray(Charsets.UTF_8)
gatt.writeCharacteristic(cmdChar)
Queue your operations. The Android BLE stack executes one GATT operation at a time and drops overlapping calls. A tiny FIFO queue — enqueue read/write/subscribe requests, fire the next one from each onCharacteristicRead / onCharacteristicWrite / onDescriptorWrite callback — turns flaky code into reliable code. This one pattern fixes most "works sometimes" BLE apps.
Step 5: Reliability patterns that make it demo-proof
A connection that works on your desk and dies during the demo is the classic outcome. Harden these five things:
- Handle disconnects and reconnect. In
onConnectionStateChange, when the state isDISCONNECTED, close the oldBluetoothGatt(gatt.close()) and attempt a reconnect with a short backoff. Never reuse a closed gatt object. - Request a bigger MTU and faster connection after service discovery if you stream data:
gatt.requestMtu(185)andgatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH). Test on your actual phone — some budget phones ignore these. - Keep callbacks off the UI thread. Do parsing in the callback, then post only the final string to the UI. Heavy work inside
onCharacteristicChangedstalls the whole BLE stack. - Use a foreground service for long sessions. If your app logs sensor data for an hour, Android will kill a backgrounded connection. A foreground service with a persistent notification keeps it alive.
- Timeout everything. Every scan, connect and write gets a timeout with a user-visible retry. "It just hangs" is never acceptable in a demo.
Common mistakes and their fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Scan finds nothing | Missing runtime permission, or location off (Android ≤ 11) | Request permissions at runtime; check location services |
| Connects but no data | Reading/writing before onServicesDiscovered |
Wait for the discovery callback before any GATT op |
setCharacteristicNotification(true) but no updates |
CCCD descriptor never written | Write ENABLE_NOTIFICATION_VALUE to UUID 0x2902 |
| Works once, then writes fail | Overlapping GATT operations | Implement the FIFO operation queue |
| Disconnects when screen locks | No foreground service | Move the connection into a foreground service |
| Two phones can't connect at once | ESP32 peripheral accepts one central | Expected BLE behaviour — design around it |
FAQ
Do I need to pair the ESP32 in Android's Bluetooth settings first?
No. BLE GATT connections happen entirely inside your app; system-level pairing is only needed if your characteristic requires encryption/bonding. For a student project, skip bonding.
Can I send data from the phone to the ESP32 continuously, like a joystick?
Yes — write to a write-property characteristic on each joystick update, but throttle to ~10–20 writes per second and use write-without-response (WRITE_TYPE_NO_RESPONSE) for throughput. Faster than that and the queue backs up.
Why does my app work on one phone but not another?
BLE behaviour varies across phone chipsets and Android skins. Always test on at least two physical devices before your demo; emulators cannot do real BLE. Budget phones are the strictest test.
Is BLE secure enough for my project?
Out of the box, BLE advertisements and unencrypted characteristics are readable by anyone nearby. That is fine for a temperature demo, not fine for a door lock. Bonding + encrypted characteristics exist but add real complexity — scope accordingly.
Should I use a library like RxAndroidBle instead of raw callbacks?
Libraries clean up the callback mess considerably and are worth it for larger apps. For learning and for a small project, the raw API plus your own queue teaches you the actual model — which is what this guide chose.
Limitations
- This guide covers the phone-as-central, ESP32-as-peripheral pattern. Phone-to-phone BLE and ESP32-as-central (for sensor mesh networks) use the same API but different roles and are out of scope.
- Throughput is modest: expect a few KB/s in practice. BLE is for telemetry and commands, not file transfer or video.
- Behaviour differs across Android versions and phone hardware; the patterns here are the stable core, but always validate on your target devices.
- Production-grade concerns — bonding, encrypted characteristics, background scanning limits on modern Android — are deliberately left out; add them only if your project genuinely needs them.
Related reading: if the ESP32 side of this is new to you, start with our BLE basics on the ESP32 and the Android app control overview for the Wi-Fi alternative. For UI work, see Jetpack Compose basics.