Sitemap

Android BLE Tools: Building a Practical BLE Inspector in Jetpack Compose

11 min readMay 29, 2026

--

Scanning, Advertising, GATT Operations, and the Timing Problems Real Devices Expose

Bluetooth Low Energy looks straightforward from a distance. Scan for devices, connect to one, discover services, read a characteristic, maybe subscribe to notifications. The API names make the workflow sound linear and friendly.

On Android, it is not quite that simple.

The Android BLE stack is callback-driven, permission-sensitive, version-dependent, and very particular about operation ordering. Reads can take longer than expected. Descriptor writes can fail if the previous operation is still settling. Notifications require both local routing and a remote CCCD write. Advertising is constrained by tiny packet sizes. The permission model changed significantly in Android 12. A GATT operation that appears to have failed may still produce a callback several seconds later.

This project is an Android-only Jetpack Compose application that makes those behaviors visible. It includes a scanner, a small central/peripheral greeting demo, health-device workflows for heart rate and blood pressure, and a BLE Spy screen that can inspect services, read characteristics, and observe notify/indicate values.

The complete source is at https://github.com/roywatson/android_ble_tools.

This is not a beginner Bluetooth tutorial. It assumes comfort with Kotlin, coroutines, Compose, and the Android project structure. The focus is on the decisions that required real device testing — the parts where the happy-path API description is not enough.

Why an Android-Only BLE Project?

Most of the examples in this portfolio series are Compose Multiplatform projects. This one deliberately returns to Android.

BLE is one of those areas where platform behavior matters more than architectural neatness. A shared abstraction can be useful, but it can also hide the details that determine whether the app works on real hardware. Android’s Bluetooth APIs have their own permission model, threading model, callback variants, advertising limitations, bonding behavior, and GATT operation rules.

The project does use small interfaces:

interface BleScanner {
fun scan(filterServiceUuids: List<String> = emptyList()): Flow<BleDevice>
}

interface BlePeripheral {
val device: BleDevice
val connectionState: Flow<ConnectionState>
val services: List<BleService>
suspend fun discoverServices()
suspend fun readCharacteristic(characteristic: BleCharacteristic): ByteArray
suspend fun writeCharacteristic(
characteristic: BleCharacteristic,
value: ByteArray,
withResponse: Boolean = true
)
fun observeCharacteristicWithSubscription(
characteristic: BleCharacteristic
): CharacteristicObservation
suspend fun stopObservingCharacteristic(characteristic: BleCharacteristic)
suspend fun disconnect()
}

But the purpose of those interfaces is not to pretend BLE is platform-neutral. They keep the Compose screens readable and keep the Android implementation isolated enough to study. The Android code is still right there, because that is where the interesting behavior is.

The App Surface

The app has five tabs.

BLE Scanner scans nearby devices and displays the device name, address, RSSI, and advertised service UUIDs. It is the smallest useful BLE workflow: start scan, collect advertisements, dedupe by address, stop scan.

Device Greeting demonstrates both sides of a BLE interaction. One phone can act as a GATT peripheral, advertising a custom service with a readable greeting characteristic. Another phone can scan for that service, connect, and read the greeting.

Health Vitals connects to standard Bluetooth SIG health services: Heart Rate ( 180D/2A37) and Blood Pressure ( 1810/2A35). It parses the measurement bytes into readable values.

BLE Spy is the inspection tool. It scans, connects, discovers services, renders a GATT tree, reads readable characteristics, observes notify/indicate characteristics, decodes common values, and always shows raw bytes.

About provides the personal branding screen for the portfolio app.

There is no dependency injection framework, no persistence layer, and no external BLE wrapper. Those choices are intentional. The app is meant to show Android BLE directly.

Permissions Are Part of the Design

Android BLE permission handling is not just manifest boilerplate. The runtime prompts are part of the user experience, and the required permissions depend on both the Android version and the operation being attempted.

Before Android 12, BLE scanning required location permission:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />

On Android 12 and newer, BLE has its own runtime permissions:

<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

The app models permissions by profile:

enum class BlePermissionProfile {
Scanner,
Central,
Peripheral,
All
}

Scanning needs scan/connect. Connecting needs connect. Advertising needs advertise/connect. Asking for everything at startup would be simpler, but it would make the app feel less trustworthy and would obscure which BLE operation requires which permission. In a sample whose purpose is to teach the platform, that distinction matters.

Scanning with callbackFlow

Android’s scanner API is callback-based:

leScanner.startScan(filters, settings, callback)

The app wraps that in a callbackFlow:

override fun scan(filterServiceUuids: List<String>): Flow<BleDevice> = callbackFlow {
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
trySend(result.toBleDevice())
}

override fun onScanFailed(errorCode: Int) {
close(IllegalStateException("BLE scan failed with error code $errorCode"))
}
}

leScanner.startScan(filters, settings, callback)

awaitClose {
runCatching { leScanner.stopScan(callback) }
}
}

This is a good fit for Compose. The ViewModel collects a stream of devices and the UI renders state. Cancellation stops the scan through awaitClose, so leaving the screen or tapping Stop does the right thing.

The scanner uses SCAN_MODE_LOW_LATENCY because this is an interactive foreground tool. That is not the right default for a background scanner or a long-running product feature. BLE code always needs a power model.

GATT Is Serialized Whether You Model It or Not

One of the most important Android BLE rules is also one of the easiest to violate: GATT operations must be serialized.

Android exposes methods that look independent:

gatt.discoverServices()
gatt.readCharacteristic(characteristic)
gatt.writeCharacteristic(characteristic, value, writeType)
gatt.writeDescriptor(descriptor, value)

But the underlying connection is not a free-for-all. If a read is in progress and a descriptor write starts too soon, the second operation may fail immediately. If a callback is slow, retrying can collide with the original operation. In real testing, this produced the exact kind of behavior that makes BLE apps feel flaky: first tap appears to do nothing, second tap works, or a retry reports that a prior command is not finished.

The implementation uses a Mutex around operations that must not overlap:

private val operationMutex = Mutex()

override suspend fun readCharacteristic(characteristic: BleCharacteristic): ByteArray {
return operationMutex.withLock {
val gattChar = findGattCharacteristic(characteristic.uuid)
?: error("Characteristic ${characteristic.uuid} not found")
val deferred = CompletableDeferred<ByteArray>()
pendingRead = deferred
try {
val started = gatt?.readCharacteristic(gattChar) ?: error("Not connected")
check(started) { "Read could not be started" }
awaitGattOperation(deferred, "Read timed out")
} finally {
if (pendingRead === deferred) pendingRead = null
}
}
}

The timeout is intentionally generous. During physical-device testing, a read from a Polar H10’s Device Name characteristic took more than eight seconds to produce its callback the first time. Retrying after five seconds was not helping; it was colliding with the still-running operation. The fix was not more clever retry logic. The fix was to model the real operation, show progress, and wait. If the operation still times out, the implementation closes the GATT connection instead of continuing to reuse a connection that Android may still consider busy.

This is the kind of detail that only appears when the app is tested with real peripherals.

Reading Characteristics

The BLE Spy read button does not assume a read is instant. When the user taps Read, the characteristic enters a reading set:

data class Connected(
val device: BleDevice,
val services: List<BleService>,
val characteristicValues: Map<String, ByteArray> = emptyMap(),
val reading: Set<String> = emptySet(),
val observations: Map<String, ObservationStatus> = emptyMap(),
val readErrors: Map<String, String> = emptyMap()
) : SpyState

The UI disables the button and shows a small spinner while the read is pending.

This sounds like a UX detail, but it is also an architectural detail. BLE operations have human-scale latency. An app that gives no feedback for five or eight seconds looks broken even when the stack is working correctly. The correct model is not “tap button, value appears.” The correct model is “tap button, operation starts, callback eventually settles state.”

Observing Notify and Indicate Characteristics

Subscribing to a BLE characteristic is more complicated than registering a callback.

For notify and indicate characteristics, the client must enable local routing and write the Client Characteristic Configuration Descriptor, or CCCD. The descriptor UUID is 2902. The value is 0x0001 for notifications and 0x0002 for indications.

The app models subscription setup as a two-part object:

data class CharacteristicObservation(
val values: Flow<ByteArray>,
val subscribed: Deferred<Unit>
)

The values are a stream of characteristic updates. The subscribed deferred completes only after the CCCD write succeeds. BLE Spy uses that distinction to show Starting until the remote device has actually accepted the subscription.

Get Roy Watson’s stories in your inbox

Join Medium for free to get updates from this writer.

The ViewModel state is explicit:

enum class ObservationStatus {
Starting,
Active,
Stopping
}

This matters because the first version of the UI treated “button tapped” as equivalent to “subscription active.” On real hardware, those are not the same thing. A Polar H10 could take long enough during the first setup that the UI looked active before values were actually arriving. The better model is to expose subscription setup as its own state.

Stopping is also a BLE operation. The implementation writes the CCCD disable value and waits for the descriptor write callback before removing the active state:

override suspend fun stopObservingCharacteristic(characteristic: BleCharacteristic) {
operationMutex.withLock {
val gattChar = findGattCharacteristic(characteristic.uuid)
?: error("Characteristic ${characteristic.uuid} not found")
val cccd = gattChar.getDescriptor(CCCD_UUID)
?: error("Characteristic ${characteristic.uuid} has no CCCD descriptor")

val descDeferred = CompletableDeferred<Unit>()
pendingDescriptorWrite = descDeferred
try {
writeDescriptorWithRetry(gatt, cccd, BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
awaitGattOperation(descDeferred, "CCCD disable timed out")
} finally {
if (pendingDescriptorWrite === descDeferred) pendingDescriptorWrite = null
characteristicChangedCallbacks.remove(characteristic.uuid.lowercase())
gatt.setCharacteristicNotification(gattChar, false)
}
}
}

There is no device-specific workaround in that design. It is simply the GATT subscription sequence modeled honestly.

Health Measurements and IEEE-11073 SFLOAT

The Health Vitals screen exists because standard services make good test targets. Heart-rate sensors and blood-pressure monitors expose well-known services and characteristics, and their measurement formats contain enough structure to be interesting.

Heart Rate Measurement ( 2A37) starts with a flags byte. One flag determines whether the heart-rate value is 8-bit or 16-bit:

fun parseHeartRate(bytes: ByteArray): Int? {
if (bytes.size < 2) return null
val flags = bytes[0].toInt() and 0xFF
return if ((flags and 0x01) != 0) {
if (bytes.size < 3) return null
((bytes[2].toInt() and 0xFF) shl 8) or (bytes[1].toInt() and 0xFF)
} else {
bytes[1].toInt() and 0xFF
}
}

Blood Pressure Measurement ( 2A35) uses IEEE-11073 SFLOAT values. SFLOAT is a 16-bit format with a 12-bit signed mantissa and a 4-bit signed exponent:

internal fun parseSFloat(bytes: ByteArray, offset: Int): Float {
val raw = ((bytes[offset + 1].toInt() and 0xFF) shl 8) or
(bytes[offset].toInt() and 0xFF)
val rawMantissa = raw and 0x0FFF
val mantissa = if (rawMantissa >= 0x0800) rawMantissa - 0x1000 else rawMantissa
val rawExp = (raw ushr 12) and 0x0F
val exponent = if (rawExp >= 0x08) rawExp - 0x10 else rawExp
return (mantissa * 10.0.pow(exponent.toDouble())).toFloat()
}

The parser also handles the optional timestamp and pulse-rate fields. BLE Spy reuses the same decoding helpers, so a standard characteristic shows a useful value like 72 bpm while still showing the raw bytes for inspection.

That last point is important. BLE tools should not hide bytes from the user. Decoding is helpful, but raw data is the ground truth.

Advertising as a Peripheral

The Device Greeting tab demonstrates Android peripheral mode. The phone opens a BluetoothGattServer, creates a primary service, adds a readable characteristic, and starts BLE advertising.

There are two Android advertising details worth calling out.

First, advertisement packets are tiny. The primary advertising packet is limited to 31 bytes. If you include both a service UUID and a local name in the same packet, it is easy to hit ADVERTISE_FAILED_DATA_TOO_LARGE. The implementation puts the service UUID in the advertisement and the name in the scan response:

val data = AdvertiseData.Builder()
.addServiceUuid(ParcelUuid.fromString(config.serviceUuid))
.setIncludeDeviceName(false)
.build()

val scanResponse = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.build()

Second, Android does not provide an API to set an arbitrary local name on AdvertiseData. If setIncludeDeviceName(true) is used, Android includes the adapter's current Bluetooth name. The implementation temporarily changes the adapter name before advertising and restores it during cleanup.

That is not a pattern to use casually in a production app. Here it is useful because the Greeting workflow is meant to behave like a small digital business card. The code comments call out the risk: if the process crashes before cleanup, the adapter name can remain changed until the user resets it or toggles Bluetooth.

BLE Spy as the Integration Test

BLE Spy is where the pieces come together.

The screen scans for devices. It can optionally resolve missing names by connecting to devices and reading the Generic Access Device Name characteristic ( 1800/2A00). It connects to a selected device, requests MTU, discovers services, and renders a GATT tree.

Each characteristic row displays:

  • known GATT name when available
  • short UUID and full UUID
  • characteristic properties
  • read action when READ is present
  • observe action when NOTIFY or INDICATE is present
  • decoded value when the format is known
  • raw hex and ASCII display for every value
  • per-characteristic progress and error state

This screen is intentionally a little more tool-like than demo-like. A useful BLE inspector has to show enough detail for someone who knows GATT to reason about what is happening. It should not reduce everything to pretty cards and friendly labels.

The most valuable part of building BLE Spy was not the UI. It was the feedback loop with physical hardware. The app exposed places where the model was wrong: reads needed longer waits, subscriptions needed explicit setup state, and operation serialization needed to be enforced rather than assumed. Those are exactly the kinds of problems a focused portfolio project should reveal.

Testing What Can Be Tested

The Android Bluetooth stack is not easy to unit test. Meaningful end-to-end validation requires physical devices, and different peripherals expose different timing behavior.

The project still has useful JVM unit tests. They cover deterministic logic:

  • UUID normalization and matching
  • heart-rate parsing
  • blood-pressure parsing
  • IEEE-11073 SFLOAT handling
  • common characteristic decoding
  • GATT name lookup
  • raw hex/ASCII display formatting
./gradlew :app:testDebugUnitTest

That is not a substitute for hardware testing. It is a guardrail around the byte-level parsing code, where regressions are easy to introduce and cheap to catch.

Wrapping Up

Android BLE rewards patience and punishes wishful thinking. The API surface suggests a sequence of simple calls, but real devices expose the timing, ordering, and state transitions underneath. Reads can be slow. Descriptor writes matter. Notifications are not active until the CCCD write completes. Advertising has packet limits. Permissions are part of the product experience. GATT operations need serialization.

The point of this project is to make those facts visible in a small, runnable app.

It is not a universal BLE library. It is a practical Android BLE example with enough surface area to be useful: scanner, advertiser, GATT server, GATT client, standard health parsing, and a BLE inspector that shows both decoded values and raw bytes.

Most importantly, it reflects what the hardware taught the code. That is where BLE projects either become reliable or become frustrating.

About the Author

Before Roy Watson wrote a line of Android code, he was programming the propellant management system for NASA’s Delta launch vehicles, building radiation detection software for Los Alamos and Sandia National Laboratories, and developing embedded vision systems for IBM’s autonomous vehicle research. After 30+ years as a systems developer, he approaches Android differently than most.

Over the past 16 years he has specialized in Android, staying current with every major evolution of the platform: Kotlin, Jetpack Compose, and now Compose Multiplatform (KMP) for iOS, Desktop, and Web. Recent clients include the New York Public Library, Auddia, Bechtel, and Goodyear. When performance work requires dropping into C/C++, SIMD intrinsics, or custom memory management, he does not hand it off.

His personal published apps have accumulated 70,000+ Android downloads (4.5-star) and 207,000+ iOS downloads (4.6-star). He studied Physics and Mathematics at Purdue University and is a member of Mensa International.

He is available for contract and full-time engagements.

roywatson.app | linkedin.com/in/roywatson3 | github.com/roywatson

If this article was useful, consider following for more pieces on Android and Compose Multiplatform architecture.

Questions, corrections, or suggestions are welcome — reach out through roywatson.app or directly at rwatson@roywatson.com.

--

--

Roy Watson
Roy Watson

Written by Roy Watson

Senior Android developer. 16 years Android, 30+ years C/C++ systems. NASA, Los Alamos, IBM Research. Jetpack Compose, KMP, NDK. https://www.roywatson.app