Skip to main content

BLEServer1 - BLEClient1

Last updated: June 19, 2026 | 8 minutes read

These two companion examples are a variant of BLEServer - BLEClient. They stream the same turn-by-turn navigation over Bluetooth Low Energy (GATT) - instruction text, distance to the turn, and a turn icon - but they differ in how the turn icon is transferred. Instead of sending a 4-byte turn-event code that the client maps to one of its own bundled drawables, BLEServer1 renders the SDK turn arrow to a bitmap, converts it to a compact grayscale stream, and sends the pixels over BLE; BLEClient1 reassembles those pixels and rebuilds the bitmap to display. The result is that the client shows the exact arrow the SDK drew - including secondary roads at the junction - without shipping any turn-arrow assets of its own.

Everything else - scanning, connecting, service discovery, subscribing, and the instruction/distance characteristics - works exactly as in the sibling example, so the sections below focus on the bitmap path.

BLEServer1: simulated navigation
BLEClient1: scanning for devices
BLEClient1: bitmap turn icon received

BLE server (BLEServer1)

Defining the navigation GATT service

As before, the server and client agree on a service UUID and three characteristic UUIDs (turn instruction, turn image, turn distance), plus the standard client-config descriptor. They are declared in the MainActivity companion object.

MainActivity.ktView on Github
companion object {
/* Navigation Service UUID */
val NAVIGATION_SERVICE: UUID = UUID.fromString("00001805-0000-1000-8000-00805f9b34fb")

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

/* Mandatory Current Turn Instruction Characteristic */
val TURN_INSTRUCTION: UUID = UUID.fromString("00002a2b-0000-1000-8000-00805f9b34fb")

/* Mandatory Current Turn Image Characteristic */
val TURN_IMAGE: UUID = UUID.fromString("00002a0f-0000-1000-8000-00805f9b34fb")

/* Mandatory Current Turn DISTANCE Characteristic */
val TURN_DISTANCE: UUID = UUID.fromString("00002a2f-0000-1000-8000-00805f9b34fb")
}

createNavigationService builds the GATT service from those UUIDs - each characteristic is readable and notifiable, and the instruction characteristic carries the client-config descriptor used for subscriptions. The service is added to the GATT server in startServer, which is started alongside BLE advertising once Bluetooth is on.

MainActivity.ktView on Github
private fun createNavigationService(): BluetoothGattService {
val service =
BluetoothGattService(NAVIGATION_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)

// Read-only characteristic, supports notifications
val turnInstruction =
BluetoothGattCharacteristic(
TURN_INSTRUCTION,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_NOTIFY,
BluetoothGattCharacteristic.PERMISSION_READ,
)

val configDescriptor = BluetoothGattDescriptor(
CLIENT_CONFIG, // Read/write descriptor
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE,
)
turnInstruction.addDescriptor(configDescriptor)
// turnImage and turnDistance are declared the same way.

service.addCharacteristic(turnInstruction)
service.addCharacteristic(turnImage)
service.addCharacteristic(turnDistance)

return service
}

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. Each instruction update refreshes the on-screen panels and pushes the new instruction, distance, and turn icon to the subscribed 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) },
),
)
}
}
}

Encoding the turn icon as a grayscale bitmap

When the turn changes, getNextTurnImage renders the SDK's abstract turn geometry to a Bitmap, kept in turnImage. Before sending, transformBitmapToGrayscale flattens that bitmap into one byte per pixel: the alpha-weighted luminance. The encoding reserves odd byte values to mark the red active-arrow pixels and keeps everything else even, so the client can tell the arrow apart from its outline when it rebuilds the image.

MainActivity.ktView on Github
private fun transformBitmapToGrayscale(
src: Bitmap,
redVal: Float = 0.299f,
greenVal: Float = 0.587f,
blueVal: Float = 0.114f,
): ByteBuffer {
val size: Int = src.width * src.height
val byteBuffer: ByteBuffer = ByteBuffer.allocate(size)

for (y in 0 until src.height) {
for (x in 0 until src.width) {
val pixel = src[x, y]
val alpha = Color.alpha(pixel)

if (alpha == 0) {
byteBuffer.put(0)
} else {
val r = Color.red(pixel); val g = Color.green(pixel); val b = Color.blue(pixel)
var value = (((redVal * r + greenVal * g + blueVal * b) * alpha) / 255).toInt()
// ... force red arrow pixels to odd values and everything else to even ...
byteBuffer.put(value.toByte())
}
}
}

return byteBuffer
}

Streaming the bitmap to subscribers

sendTurnImage transmits the grayscale buffer row by row using a simple run-length scheme: for each line it sends the first/last non-empty x positions (and the longest empty zone inside that interval, when present), then streams only the meaningful pixels in 20-byte BLE chunks. A leading 1 byte marks the start of a new image, and a 0 byte (sent when navigation ends) tells the client to clear the icon. The image dimensions themselves are returned from the read request on the TURN_IMAGE characteristic.

MainActivity.ktView on Github
private fun sendTurnImage() {
turnImage?.let {
val byteBuffer: ByteBuffer = transformBitmapToGrayscale(it)
val array = byteBuffer.array()

// Sends a contiguous run of pixels in 20-byte BLE chunks.
val sendRange = { offset: Int, itemsCount: Int -> /* ... */ }

// Marks the beginning of a new image.
notifyRegisteredDevices(byteArrayOf(1), TURN_IMAGE)

for (y in 0 until turnImageSize) {
// For each row: find the first/last non-empty pixels (minX..maxX) and the
// longest empty zone (minX1..maxX1) inside the (minX..maxX) interval, send
// those bounds, then the "non empty" pixels.
// ...
}
}
}

The client first reads the TURN_IMAGE characteristic to learn the icon size; the server answers with the side length and total pixel count packed as two ints.

MainActivity.ktView on Github
TURN_IMAGE == characteristic.uuid -> {
Log.i(TAG, "Read turn image")
val intToBytes = { i: Int, j: Int ->
ByteBuffer.allocate(Int.SIZE_BYTES * 2).putInt(i).putInt(j).array()
}

bluetoothGattServer?.sendResponse(
device,
requestId,
BluetoothGatt.GATT_SUCCESS,
0,
intToBytes(turnImageSize, turnImageSize * turnImageSize),
)
}

BLE client (BLEClient1)

Scanning for devices, connecting to the GATT server, discovering the navigation service, and subscribing to notifications all work exactly as in BLEServer - BLEClient. The instruction text and distance are handled the same way too; only the turn-image handling is different.

Reassembling the bitmap stream

BLEService.broadcastUpdate rebuilds the grayscale buffer from the incoming TURN_IMAGE packets, mirroring the server's encoding. An 8-byte packet carries the image dimensions (read with read4BytesFromBuffer), a 1-byte packet signals start (1) or clear (0), 2- and 4-byte packets carry per-row bounds, and the remaining packets fill in pixel runs. Once a full image has been assembled, it is forwarded in an ACTION_DATA_AVAILABLE broadcast tagged with type 2.

BLEService.ktView on Github
} else if (TURN_IMAGE == characteristic.uuid) {
if ((data.size == 8) && (turnImageLineSize == 0)) {
// Header: image side length + total pixel count.
turnImageSize = read4BytesFromBuffer(data, 0)
turnImageData = ByteArray(read4BytesFromBuffer(data, 4))
turnImageDataOffset = 0
return
} else if ((data.size == 1) && (turnImageLineSize == 0)) {
// 1 = start of a new image, 0 = clear the icon.
// ...
} else if ((data.size == 2) && (turnImageLineSize == 0)) {
// Row bounds minX..maxX (empty row or simple span).
// ...
} else if ((data.size == 4) && (turnImageLineSize == 0)) {
// Row bounds with an empty area minX1..maxX1 inside minX..maxX interval.
// ...
} else {
// Pixel-run payload: copy into turnImageData until the line is complete.
// ...
}
// When turnImageDataOffset == turnImageData.size the image is complete:
// intent.putExtra(EXTRA_TYPE, 2); intent.putExtra(EXTRA_DATA, turnImageData)
}

Rebuilding and displaying the bitmap

NavigationActivity.createBitmap turns the reassembled grayscale bytes back into an ARGB_8888 bitmap, applying the same odd/even convention the server used: odd values become red arrow pixels, even values become opaque gray. The receiver then sets the bitmap on the turn-icon view.

NavigationActivity.ktView on Github
fun createBitmap(img: ByteArray?, width: Int, height: Int): Bitmap? {
if ((img == null) || (width <= 0) || (height <= 0) || (img.size < (width * height))) {
return null
}

var index = 0
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)

for (y in 0 until height) {
for (x in 0 until width) {
val g = img[index++].toUByte().toInt()
if (g % 2 == 1) {
result[x, y] = Color.argb(g, 255, 0, 0) // red arrow pixel
} else {
result[x, y] = Color.rgb(g, g, g) // gray outline / fill
}
}
}

result.density = DisplayMetrics.DENSITY_MEDIUM
return result
}
NavigationActivity.ktView on Github
} else if (type == 2) {
val data = intent.getByteArrayExtra(BLEService.EXTRA_DATA)
if ((data != null) && data.isNotEmpty()) {
if (data.size > 1) {
bluetoothLeService?.let {
binding.topPanel.visibility = View.VISIBLE
val bmp = createBitmap(data, it.turnImageSize, it.turnImageSize)
binding.navIcon.setImageBitmap(bmp)
}
} else {
binding.topPanel.visibility = View.GONE
}
}
}