Skip to main content

BLEServer - BLEClient

Last updated: June 19, 2026 | 8 minutes read

These two companion examples stream turn-by-turn navigation over Bluetooth Low Energy (BLE). BLEServer runs a simulated route, renders it on a map, and publishes the next-turn data - instruction text, distance to the turn, and a turn-event code - as a BLE GATT service. BLEClient scans for nearby BLE devices, connects to the server, discovers that navigation service, subscribes to its characteristics, and displays the instructions it receives, without a map of its own. Together they show how a phone running navigation can drive a separate accessory display (for example, a smart watch or a bike computer).

Both apps agree on a small set of GATT UUIDs: one navigation service containing three characteristics - turn instruction, turn image (event), and turn distance - each notifiable through the standard client-config descriptor.

BLEServer: simulated navigation
BLEClient: scanning for devices
BLEClient: instruction received

BLE server (BLEServer)

Defining the navigation GATT service

The server and client share the same UUIDs. On the server they live in the MainActivity companion object: one service UUID and three characteristic UUIDs, plus the standard client-characteristic-configuration descriptor used to toggle notifications.

MainActivity.ktView on Github
companion object {
/** Bluetooth GATT service UUID for the navigation data. */
val NAVIGATION_SERVICE: UUID = UUID.fromString("00011805-0000-1000-8000-00805f9b34fb")

/** Standard Client Characteristic Config Descriptor UUID. */
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")

/** Characteristic UUID carrying the next turn instruction text. */
val TURN_INSTRUCTION: UUID = UUID.fromString("00012a2b-0000-1000-8000-00805f9b34fb")

/** Characteristic UUID carrying the 4-byte turn event type / roundabout data. */
val TURN_IMAGE: UUID = UUID.fromString("00012a0f-0000-1000-8000-00805f9b34fb")

/** Characteristic UUID carrying the distance to the next turn. */
val TURN_DISTANCE: UUID = UUID.fromString("00012a2f-0000-1000-8000-00805f9b34fb")
}

NavBluetoothManager builds the GATT service from those UUIDs. Each characteristic is read-only and supports notifications, and each is given a writable CLIENT_CONFIG descriptor so clients can subscribe. The characteristics are then added to a single primary service.

NavBluetoothManager.ktView on Github
private val turnInstruction = BluetoothGattCharacteristic(
MainActivity.TURN_INSTRUCTION, // Read-only characteristic, supports notifications
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ,
)
// turnImage and turnDistance are declared the same way, each paired with a
// CLIENT_CONFIG descriptor.

private val gattService =
BluetoothGattService(MainActivity.NAVIGATION_SERVICE, SERVICE_TYPE_PRIMARY)

init {
turnInstruction.addDescriptor(turnInstructionDescriptor)
turnImage.addDescriptor(turnImageDescriptor)
turnDistance.addDescriptor(turnDistanceDescriptor)
gattService.apply {
addCharacteristic(turnInstruction)
addCharacteristic(turnImage)
addCharacteristic(turnDistance)
}
}

Advertising and starting the GATT server

start() opens the GATT server and begins BLE advertising so clients can discover it. The advertisement is connectable and includes the device name and the navigation service UUID; the GATT server is opened with the callback that handles client read/write requests.

NavBluetoothManager.ktView on Github
private fun startAdvertising() {
val bluetoothLeAdvertiser = bluetoothManager.adapter.bluetoothLeAdvertiser
bluetoothLeAdvertiser?.let {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
.setConnectable(true)
.setTimeout(0)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
.build()

val data = AdvertiseData.Builder().setIncludeDeviceName(true)
.setIncludeTxPowerLevel(false)
.addServiceUuid(ParcelUuid(MainActivity.NAVIGATION_SERVICE))
.build()

it.startAdvertising(settings, data, advertiseCallback)
}
}

private fun startServer() {
bluetoothGattServer = bluetoothManager.openGattServer(context, gattServerCallback)
bluetoothGattServer.addService(gattService)
}

Running the simulated navigation

Once the worldwide road map is up to date, the server starts a simulated route from Amsterdam to Paris with a NavigationService. The navigationListener receives instruction updates as the simulation advances; those updates are what gets pushed to subscribed BLE clients.

MainActivity.ktView on Github
private fun startSimulation() = SdkCall.execute {
val waypoints = arrayListOf(
Landmark("Amsterdam", 52.3585050, 4.8803423),
Landmark("Paris", 48.8566932, 2.3514616),
)

val errorCode = navigationService.startSimulation(waypoints, navigationListener, routingProgressListener)
if (errorCode != GemError.NoError) {
runOnUiThread {
showDialog(
getString(
R.string.start_simulation_error,
SdkCall.runSynced { GemError.getMessage(errorCode, this) },
),
)
}
}
}

Notifying subscribed clients of turn updates

Whenever an instruction changes, the listener updates the on-screen panels and pushes the new values to the connected devices: the turn-event bytes go to TURN_IMAGE, the instruction text to TURN_INSTRUCTION, and the distance to TURN_DISTANCE. Each value is only sent when it actually changes.

MainActivity.ktView on Github
// Check whether the turn icon changed and notify BLE devices if so.
val sameTurnImage = TSameImage()
val newTurnImage = getNextTurnImage(instr, turnImageSize, turnImageSize, sameTurnImage)
if (!sameTurnImage.value) {
// ... pack the turn event / roundabout data into the turnEvent byte array ...
binding.navIcon.setImageBitmap(newTurnImage)
notifyRegisteredDevices(turnEvent, TURN_IMAGE)
}

if (instrText != binding.navInstruction.text) {
binding.navInstruction.text = instrText
sendTurnInstruction()
}

if (instrDistance != binding.instrDistance.text) {
binding.instrDistance.text = instrDistance
sendTurnDistance()
}

The instruction text can exceed the ~20-byte BLE notification payload, so sendTurnInstruction first sends a one-byte length header and then streams the text in 20-byte chunks - the client reassembles them on the other side.

MainActivity.ktView on Github
private fun sendTurnInstruction() {
var turnInstruction = binding.navInstruction.text.toString()
if (turnInstruction.length > 128) {
turnInstruction = turnInstruction.substring(0, 125).plus("...")
}
if (turnInstruction.isEmpty()) {
turnInstruction = " "
}

val byteArray = turnInstruction.toByteArray()

// First packet carries the total byte count; subsequent packets carry the text in
// 20-byte BLE MTU-sized chunks.
notifyRegisteredDevices(byteArrayOf(byteArray.size.toByte()), TURN_INSTRUCTION)

val n = byteArray.size / 20
val r = byteArray.size % 20
var tmp = ByteArray(20)

for (i in 1..n) {
System.arraycopy(byteArray, (i - 1) * 20, tmp, 0, 20)
notifyRegisteredDevices(tmp, TURN_INSTRUCTION)
}

if (r > 0) {
tmp = ByteArray(r)
System.arraycopy(byteArray, n * 20, tmp, 0, r)
notifyRegisteredDevices(tmp, TURN_INSTRUCTION)
}
}

BLE client (BLEClient)

Scanning for nearby BLE devices

MainActivity lists the BLE devices it finds. Scanning runs for a fixed period through the system BluetoothLeScanner; every result is added to the list adapter, and tapping a device opens the NavigationActivity for that address.

MainActivity.ktView on Github
private fun startBLEScan() {
// ... requires the BLUETOOTH_SCAN permission ...
Log.d(tag, "startBLEScan()")
bluetoothAdapter?.bluetoothLeScanner?.startScan(mScanCallback)
}

private val mScanCallback: ScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
leDeviceListAdapter?.addDevice(result.device)
binding.progressBar.visibility = View.GONE
}
// onBatchScanResults / onScanFailed omitted
}

Connecting and discovering the navigation service

The connection logic lives in BLEService. connect opens a GATT connection to the chosen device; when the connection succeeds, the GATT callback calls discoverServices(). Once services are discovered, NavigationActivity.parseGattServices locates the navigation service by its UUID and kicks off the first characteristic read.

BLEService.ktView on Github
fun connect(address: String?): Boolean {
if ((bluetoothAdapter == null) || (address == null)) {
Log.w(tag, "BluetoothAdapter not initialized or unspecified address.")
return false
}
// ... reconnect path when the same device was connected before ...

val device = bluetoothAdapter?.getRemoteDevice(address) ?: return false

// autoConnect = false: connect directly to the device.
bluetoothGatt = device.connectGatt(this, false, mGattCallback, BluetoothDevice.TRANSPORT_LE)
bluetoothDeviceAddress = address
connectionState = STATE_CONNECTING

return true
}
NavigationActivity.ktView on Github
private fun parseGattServices(gattServices: List<BluetoothGattService>?) {
if (gattServices != null) {
for (gattService in gattServices) {
if (gattService.uuid == SampleGattAttributes.NAVIGATION_SERVICE) {
characteristics = gattService.characteristics
for (gattCharacteristic in characteristics) {
if ((gattCharacteristic.uuid == SampleGattAttributes.TURN_IMAGE) &&
((gattCharacteristic.properties or BluetoothGattCharacteristic.PROPERTY_READ) > 0)
) {
bluetoothLeService?.readCharacteristic(gattCharacteristic)
break
}
}
break
}
}
}
}

Subscribing to notifications

After the initial reads chain through the characteristics, the client enables notifications on each notifiable characteristic. This both flips the local notification flag and writes the standard ENABLE_NOTIFICATION_VALUE to the CLIENT_CONFIG descriptor - which is exactly what the server's onDescriptorWriteRequest uses to register the device as a subscriber.

BLEService.ktView on Github
fun setCharacteristicNotification(characteristic: BluetoothGattCharacteristic, enabled: Boolean) {
// ... requires the BLUETOOTH_CONNECT permission ...
bluetoothGatt?.setCharacteristicNotification(characteristic, enabled)

val descriptor = characteristic.getDescriptor(CLIENT_CONFIG)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
bluetoothGatt?.writeDescriptor(descriptor)
} else {
bluetoothGatt?.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
)
}
}

Receiving and displaying the instruction

Incoming notifications arrive in BLEService.broadcastUpdate. The turn instruction is reassembled from its chunks (using the length header sent first), while distance and turn-event payloads are forwarded directly. Each value is wrapped in an ACTION_DATA_AVAILABLE broadcast tagged with a type code.

BLEService.ktView on Github
private fun broadcastUpdate(characteristic: BluetoothGattCharacteristic, data: ByteArray) {
val intent = Intent(ACTION_DATA_AVAILABLE)

if (data.isNotEmpty()) {
if (TURN_INSTRUCTION == characteristic.uuid) {
// First packet is the total size; following packets are copied into
// turnInstructionData until the full string is reassembled.
if ((turnInstructionSize == 0) && (data.size == 1)) {
turnInstructionDataOffset = 0
turnInstructionSize = data[0].toInt()
turnInstructionData = ByteArray(turnInstructionSize)
return
}
// ... copy this chunk; return early until the instruction is complete ...
intent.putExtra(EXTRA_TYPE, 0)
intent.putExtra(EXTRA_DATA, String(turnInstructionData))
} else if (TURN_DISTANCE == characteristic.uuid) {
intent.putExtra(EXTRA_TYPE, 1)
intent.putExtra(EXTRA_DATA, String(data))
} else if (TURN_IMAGE == characteristic.uuid) {
intent.putExtra(EXTRA_TYPE, 2)
intent.putExtra(EXTRA_DATA, data)
} else {
return
}

sendBroadcast(intent)
}
}

NavigationActivity listens for those broadcasts and updates its panel: the instruction text and distance are set straight onto the views, and the turn-event bytes are mapped to a local turn-arrow drawable.

NavigationActivity.ktView on Github
BLEService.ACTION_DATA_AVAILABLE -> {
val type = intent.getIntExtra(BLEService.EXTRA_TYPE, -1)
when (type) {
0 -> { // turn instruction text
binding.topPanel.visibility = View.VISIBLE
binding.navInstruction.text = intent.getStringExtra(BLEService.EXTRA_DATA)
}

1 -> { // distance to the next turn
binding.topPanel.visibility = View.VISIBLE
binding.instrDistance.text = intent.getStringExtra(BLEService.EXTRA_DATA)
}

2 -> { // turn event bytes -> pick a local turn-arrow drawable for navIcon
val data = intent.getByteArrayExtra(BLEService.EXTRA_DATA)
// ... map data[0] (and roundabout slots) to an R.drawable.ic_nav_* icon ...
}
}
}