How to Build an Android BLE App That Talks to Your ESP32

Most student IoT builds stop at the serial monitor. This guide shows how to build an Android app that connects to an ESP32 over BLE: GATT services and characteristics, Android 12+ permissions, scanning, connecting, notifications, and the reliability patterns that make it demo-proof.

Written by Projectech11 min readPublished
For Android and embedded students who already have an ESP32 prototype and want a phone app that reads its sensors and sends it commands over Bluetooth Low Energy. Topics: Bluetooth Low Energy, GATT, ESP32, Kotlin, Android SDK, nRF Connect
Blueprint-style technical illustration of a smartphone exchanging wireless signals with an ESP32 development board
Illustration generated for this guide.
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).

  1. 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.
  2. 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.
  3. 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.
  4. Descriptors — Small attributes attached to a characteristic that configure it. The important one is the Client Characteristic Configuration Descriptor (CCCD, UUID 0x2902): you write 0x0001 to it to turn on notifications. Skip this and your "subscribe" call silently does nothing — the most common BLE bug there is.
  5. 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:

  1. Handle disconnects and reconnect. In onConnectionStateChange, when the state is DISCONNECTED, close the old BluetoothGatt (gatt.close()) and attempt a reconnect with a short backoff. Never reuse a closed gatt object.
  2. Request a bigger MTU and faster connection after service discovery if you stream data: gatt.requestMtu(185) and gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH). Test on your actual phone — some budget phones ignore these.
  3. Keep callbacks off the UI thread. Do parsing in the callback, then post only the final string to the UI. Heavy work inside onCharacteristicChanged stalls the whole BLE stack.
  4. 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.
  5. 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.

More project guides

More in Android

Controlling Arduino and ESP32 from an Android App: Bluetooth, BLE and WiFi

Most app-to-hardware student builds pick one of three links: Bluetooth Classic via an HC-05 (~10 m, dead-simple serial), BLE on the ESP32 (low power, app talks through GATT characteristics), or WiFi (ESP32 as hotspot or on your LAN, HTTP or MQTT). This guide walks all three, shows the HC-05 AT-mode wiring, a minimal message protocol you can design in ten minutes, Flutter vs native Kotlin tradeoffs, and the Android 12+ BLUETOOTH_CONNECT permission bug that silently breaks most student apps. End with a demo-day checklist and a failure table so your demo survives the classroom.

Read guide

Best 10 Android Utility App Projects for Daily Life

Clients keep asking for one thing: an app project they can actually install and use every day — not a demo that only runs during the viva. This guide lists 10 real, published Projectech Android projects built exactly for that: medicine reminders, a parking-spot saver, vehicle maintenance logs, PUC reminders, vegetable price tracking, WiFi coverage mapping, offline-first notes, a safety SOS app, a study planner and spaced-repetition flashcards. Every entry links to its live project page with full source, documentation and build details.

Read guide

Best 10 Smart Attendance System Projects for Students

Attendance systems are one of the themes clients ask Projectech about most, so this list was picked from the enquiry patterns: ten genuinely different ways to build an attendance system — biometric, card-based, phone-based, and wireless — each entry linked to its live project page. Every project is real and built-to-order on projectech.in, with the tech stack and build angle written from what each page actually describes.

Read guide