This library provides support for connecting to Bluetooth devices as a Client: scanning for devices, connecting to them and reading, writing and subscribing to their attributes.
Installing
This library is available on Maven Central. You can import Kaluga Bluetooth Client as follows:
repositories {
// ...
mavenCentral()
}
// ...
dependencies {
// ...
implementation("com.splendo.kaluga:bluetooth-client:$kalugaVersion")
}
Usage
Check out the full documentation
Create a BluetoothClient through the BluetoothClientBuilder (or via BluetoothBuilder.createClient() from the bluetooth module). This gives you access to a Flow of Bluetooth devices. To scan for devices simply call
bluetooth.startScanning(setOf(someUUID))
launch {
bluetooth.devices().collect {} // Gets all devices scanned by the last filter
}
///
bluetooth.stopScanning()
Bluetooth Scanning is managed by a state machine that will keep running as long as it is observed.
It will automatically handle Permissions and Enabling/Disabling bluetooth on the device, although this behaviour can be disabled via the builder.
Devices are returned as a flow of Device objects, which manages the connection state of each device.
By default the BluetoothClient removes all scanned devices when starting a new scan. Use CleanMode to change this behaviour. Using allDevices or scannedDevices scans for a specific can be accessed.
Devices can be grabbed via a getter method. This returns a flow on which the device can be connected/disconnected from.
launch {
bluetooth.devices()[someUUID].advertisement().collect { advertisementData ->
// handle Advertisement Data
}
}
// Connect to a device
bluetooth.devices()[someUUID].connect()
// Disconnects a device
bluetooth.devices()[someUUID].disconnect()
Accessing Attributes
From a device it is possible to grab any Gatt Attribute using its UUID. Use services to get a List of RemoteService objects containing the currently discovered services. After this use getters to access services, characteristics and so on.
// In a suspend method
val descriptor = bluetooth.devices()[someUUID].services()[serviceUUID].characteristics()[characteristicUUID].descriptors()[descriptorUUID].first()
Accessors may fail if the attribute with the given UUID is not available. To only get services after discovery has completed, use discoveredServices. Alternatively, use getOrNull to only get an attribute if it is available to the device:
// In a suspend method
val characteristic = bluetooth.devices()[someUUID].discoveredServices()[serviceUUID].characteristics().getOrNull(characteristicUUID).first()
Read, Write, and Notify
From a RemoteCharacteristic or RemoteDescriptor data can be read or written to:
// Read
when (val response = characteristic.read()) {
is GattResponse.ReadSuccess -> println("Did Read ${response.value.toHexString()}")
is GattResponse.WriteError -> println("Failed with ${response.statusCode}")
}
// Write
descriptor.write(byteArrayOf())
Or for a RemoteCharacteristic subscribe to a notification:
val subscription = characteristic.subscribe {
println("Notified of ${it.toHexString()}")
}
subscription.unsubscribe() // Must be called when done
// Alternative approach:
characteristic.value().collect {
println("Notified of ${it.toHexString()}")
}
All these methods have alternative implementations that automatically (de)serialize a ByteArray into a kotlin class. See the BluetoothFormat documentation in bluetooth-base.
Binding
Using the bind methods, an object can be make to transform based on bluetooth communication:
val value = "".bind(device) {
service(serviceUUID) {
characteristic(serviceUUID) {
observe {
mutate {
onNotification { data ->
data.toHexString()
}
}
}
// Observe flows or channels to trigger read/writes
flowOf(1, 2 ,3).collectTo {
triggerRead {
onRead { response, trigger ->
println("$trigger Did read $response.toHexString()")
}
}
}
}
}
}
Android
When using automatic permissions by default only the relevant Bluetooth permissions are asked for, and not the location permission (unless the Android version is lower than 12 where it is always required). Make sure you include android:usesPermissionFlags="neverForLocation", unless you do use Bluetooth to determine location, in which case you can use the useLocation flag in BaseScanner.Settings.
JavaScript and WebAssembly (Web Bluetooth)
On the JS family (js and wasmJs) the client is backed by the Web Bluetooth API. This API is Chromium-only, requires a secure context (HTTPS or localhost), and is central/client only — there is no peripheral/GATT-server role (the bluetooth-server module is not available for web). RSSI, MTU negotiation and pairing have no Web Bluetooth equivalent: reading RSSI and pairing are no-ops and an MTU request resolves to GattResponse.MTUNotPermitted.
Scanning is an “Add Device” overlay
Web Bluetooth has no free-running scan, and its navigator.bluetooth.requestDevice device picker must be opened from a user gesture. The DefaultScanner therefore does not call requestDevice directly: while a scan is active it renders an “Add Device” overlay in the DOM. Each press of its button opens the system picker (from within the click handler, satisfying the gesture requirement) and adds the chosen device to the scan results, so a single scan can collect any number of devices. The overlay carries its own close (✕) button and is removed automatically when scanning stops.
Optional services
Web Bluetooth only grants access to GATT services that are declared up front — a service that is not advertised, or only available after connecting, is unreachable unless it is listed as an optional service. Supply these through the scanner’s builder; they are applied to every picker invocation:
val builder = BluetoothClientBuilder(
scannerBuilder = DefaultScanner.Builder(
optionalServices = listOf(heartRateServiceUUID, batteryServiceUUID),
),
)
The per-scan Filter passed to startScanning is separate: it only narrows which devices the picker shows (by advertised services), whereas optionalServices is the advertisement-independent access allowlist.
Permissions
There is no upfront Bluetooth permission prompt on the web — access is granted per device when the user picks one through the overlay. The Bluetooth permission therefore reports granted whenever Web Bluetooth is available (the browser’s experimental navigator.permissions.query({ name: 'bluetooth' }) is not relied upon, as it is unsupported in most browsers).
Styling the overlay
The overlay is configured with WebDevicePickerSettings (passed to DefaultScanner.Builder):
DefaultScanner.Builder(
optionalServices = listOf(heartRateServiceUUID),
pickerSettings = WebDevicePickerSettings(
title = "Bluetooth Devices",
addButtonLabel = "Add Device",
emptyLabel = "No devices added yet",
cssClassPrefix = "kaluga-bluetooth", // default
containerId = null, // mount in document.body when null
),
)
No inline styles are applied — every element gets a class derived from cssClassPrefix, so the host page styles it entirely through CSS:
| Class (default prefix) | Element |
|---|---|
kaluga-bluetooth-overlay |
the overlay container |
kaluga-bluetooth-title |
the heading |
kaluga-bluetooth-close |
the ✕ dismiss button |
kaluga-bluetooth-list |
the added-device list (<ul>) |
kaluga-bluetooth-list-item |
a device entry |
kaluga-bluetooth-list-empty |
the placeholder shown before any device is added |
kaluga-bluetooth-button |
the “Add Device” button |
For example, to float it in the top-right corner:
.kaluga-bluetooth-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
.kaluga-bluetooth-close { position: absolute; top: 8px; right: 8px; border: none; background: transparent; cursor: pointer; }
.kaluga-bluetooth-button { width: 100%; padding: 8px 12px; border: none; border-radius: 6px; background: #1a73e8; color: #fff; cursor: pointer; }
Notes
There is a major difference when it comes to the reporting of scanned devices between Android and iOS. Android report multiple scans of the same device, whereas iOS filters them out.
To align the behaviour across platforms the CBCentralManagerScanOptionAllowDuplicatesKey option is enabled on iOS. It can be set to another value using ScanSettings as shown above.
Testing
Use the test-utils-bluetooth-client module to get mockable Bluetooth classes.