The library simplifies usage of Android Bluetooth Low Energy on Android. It is a wrapper around native API and uses Kotlin Coroutines for asynchronous operations. The usage is designed to be more natural according to the BLE specification.
Important
This library is in still under construction. Scanning and client should work, but the API may still change. Any feedback is welcome.
Expected changes:
- Logging
- Migrating from SLF4J to log delegates to allow logging for different devices separately.
- Improving logged events
- Higher level API
- We found the library to be difficult to use with multiple independent services. We are trying to
come up with a solution that would allow use single
Peripheralwith different service implementations.
- We found the library to be difficult to use with multiple independent services. We are trying to
come up with a solution that would allow use single
We are working on version 2 of the library. The new version will be a complete rewrite of the library.
Current status:
- Central role
- Scanning
- Scanning for nearby Bluetooth LE devices
- Ranging and monitoring device
- Obtaining list of connected devices
- Establishing connection
- Direct (
autoConnect = false) - Using AutoConnect feature
- Service discovery and subscribing to services changes
- Bonding and observing bond state
- Direct (
- Basic GATT operations
- Reading / writing characteristics
- Enabling notifications / indications
- Subscribing to value changes
- Requesting highest MTU
- Advanced GATT operations
- Requesting PHY
- Subscribing to PHY changes
- Requesting connection priority
- Subscribing to connection parameter changes
- Reliable write
- Mock implementation
- Basic GATT operations
- Advanced GATT operations
- Bonding
- Scanning
- Peripheral role
- Advertising
- Setting up GATT server
- GATT operations
- Mock implementation
Dokka documentation can be found here.
The library relies on Environments when accessing Bluetooth API. It provides 2 implementations:
- native, using
NativeAndroidEnvironment - mock, using
MockAndroidEnvironment
It is recommended to use them in different flavors, or use the mock one in tests.
To create the native environment, use:
val environment = NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)As a parameter, specify whether the Manifest.permission.BLUETOOTH_SCAN was defined with, or without
the neverForLocation flag. The library requires this information to check, whether the Location
Permission is needed for scanning for Bluetooth LE devices.
Define a mock environment using one of predefined implementations:
val environment = MockAndroidEnvironment.Api31(
// Customize the mock environment using various flags:
isNeverForLocationFlagSet = false,
isBluetoothScanPermissionGranted = false,
isBluetoothConnectPermissionGranted = false,
isBluetoothAdvertisePermissionGranted = false,
// ... and many more.
)There are number of implementations representing platforms with significant API changes related to Bluetooth:
- Api21 - initial supported API,
- Api23 - location and
ACCESS_FINE_LOCATIONpermission required for scanning, - Api26 - PHY 2 and Coded PHY added,
- Api31 - Bluetooth permissions added.
Use LatestApi() as a typealias for the implementation matching the latest implementation.
Remember, that the NativeAndroidEnvironment registers BroadcastReceivers and needs to be closed
using close() when the environment is no longer needed. This may be done in onDestroy() of the
Activity when the app is being destroyed.
You may use Hilt and the ActivityRetainedComponent:
@Module
@InstallIn(ActivityRetainedComponent::class)
object EnvironmentModule {
@ActivityRetainedScoped
@Provides
fun provideEnvironment(
@ApplicationContext context: Context,
lifecycle: ActivityRetainedLifecycle,
): NativeAndroidEnvironment {
// Make sure the environment is closed when the lifecycle is cleared.
// This will unregister the broadcast receiver.
return NativeAndroidEnvironment.getInstance(context, true)
.also { lifecycle.addOnClearedListener { it.close() } }
}
}To use a phone as a central device you need to create a CentralManager.
Central Manager allows to scan and connect to Bluetooth LE accessories.
Create an advertiser using the Environment:
val centralManager = CentralManager.Factory.native(environment, scope)A mock central manager may be used to test the app using mocked Bluetooth LE devices. First,
define the PeripheralSpecs of test devices:
private val hrm = PeripheralSpec
.simulatePeripheral(
identifier = "11:22:33:44:55:66",
proximity = Proximity.FAR,
) {
advertising(
parameters = LegacyAdvertisingSetParameters(
connectable = true,
interval = 500.milliseconds,
txPowerLevel = -7,
),
) {
ServiceUuid(shortUuid = 0x180D) // Heart Rate Service
CompleteLocalName("Nordic HRM")
}
}You may define them as connectable, or non-connectable. See the Sample for an example how to define the connectivity parameters and define services and device behavior.
With mock peripherals defined, create a mock central manager:
val centralManager = CentralManager.mock(environment, scope)
.apply {
simulatePeripherals(listOf(hrm /*, ... */))
}Scanning for Bluetooth LE accessories can be done using the scan method of a Central Manager:
centralManager
.scan(1250.milliseconds) {
ServiceUUID(someServiceUUID)
Any {
Name("MyName")
Name("OtherName")
}
}
.distinctByPeripheral()
.map {
it.peripheral
}
.onEach { peripheral ->
// Do something with the peripheral
}
.launchIn(scope)Use the Central Manager also to connect to the peripherals:
scope.launch {
try {
withTimeout(10000) {
centralManager.connect(
peripheral = peripheral,
options = CentralManager.ConnectionOptions.Direct(
timeout = 3.seconds,
retry = 2,
retryDelay = 1.seconds,
Phy.PHY_LE_1M,
),
// options = CentralManager.ConnectionOptions.AutoConnect,
)
Timber.i("Connected to ${peripheral.name}!")
}
// The first time the app connects to the peripheral it needs to initiate
// observers for various parameters.
// The observers will get cancelled when the connection scope gets cancelled,
// that is when the device is manually disconnected in case of auto connect,
// or disconnects for any reason when auto connect was false.
peripheral.phy
.onEach {
Timber.i("PHY changed to: $it")
}
.onEmpty {
Timber.w("PHY didn't change")
}
.onCompletion {
Timber.d("PHY collection completed")
}
.launchIn(this)
} catch (e: Exception) {
Timber.e(e, "Connection attempt failed")
}
}GATT Services are available as StateFlow<RemoteService>. When the flow is collected, the library
will discover services and emit the list of services. The list is updated when the Service Changed
indication is received or when the device gets disconnected. The flow's initial value is null.
It is possible to subscribe to services() even before the device is connected. The flow will
be emitted when the services are available. The previous services should not be used when a new
value is emitted, including the null value. Any GATT operation on such attribute will throw an
InvalidAttributeException exception.
peripheral.services()
.onEach { services ->
Timber.i("Services changed: $services")
services.forEach { remoteService ->
// Do something with the service. See GATT operations below.
}
}
.onEmpty {
Timber.w("No services found")
}
.onCompletion {
Timber.d("Service collection completed")
}
.launchIn(scope)Request maximum MTU using:
peripheral.requestHighestValueLength()Note, that it is not possible to specify the MTU value. The device will automatically use the maximum value it supports.
You may also do this automatically using a flag in connection options:
val options = CentralManager.ConnectionOptions.Direct(
timeout = 3.seconds,
retry = 2,
retryDelay = 1.seconds,
automaticallyRequestHighestMtu = true
)
centralManager.connect(
peripheral = peripheral,
options = options
)
Timber.i("Connected to ${peripheral.name}!")The result can be read using:
val length = peripheral.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
Timber.i("Maximum write length for $writeType: $length bytes")To write a value to a characteristic or a descriptor, use write(...) method:
val ledCharacteristic = remoteService.characteristics.firstOrNull { it.uuid == ledCharacteristicUuid }
Timber.i("($ce) Turning LED on...")
ledCharacteristic?.write(byteArrayOf(0x01))Note, that write(...) is synchronous and can only send up to the maximum value length for a given
write type. If you need to send longer data, use chunked(...) method
(see here)
to split the ByteArray or Flow<ByteArray> into chunks and send them in a loop.
val veryLongData = byteArrayOf(1, 2, 3, 4, 5 /* ... */)
val length = peripheral.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
veryLongData.chunked(length).forEach {
// Writing data one chunk after another:
characteristic.write(it)
}Sometimes, a write operation to a characteristic is expected to trigger a notification or an indication.
If the notification is sent immediately, the library may not be able to subscribe for it before
it is received, resulting in data lost. In such situation, use waitForValueChange:
// Registering for a notification:
Timber.i("($ce) Awaiting for button press to start LED blinking...")
val result = buttonCharacteristic?.waitForValueChange {
// Sending command that will trigger a notification:
Timber.i("($ce) Turning LED on...")
ledCharacteristic?.write(byteArrayOf(0x01))
}
Timber.i("($ce) Button change to 0x${result?.toHexString()}")remoteService.characteristics.forEach { remoteCharacteristic ->
try {
Timber.w("Reading value of ${remoteCharacteristic.uuid}...")
val value = remoteCharacteristic.read()
Timber.i("Value of ${remoteCharacteristic.uuid}: 0x${value.toHexString()}")
} catch (e: Exception) {
// An exception is thrown when a characteristic is not readable, or an error occurs.
Timber.e("Failed to read ${remoteCharacteristic.uuid}: ${e.message}")
}
}remoteService.characteristics.forEach { remoteCharacteristic ->
try {
Timber.w("Subscribing to ${remoteCharacteristic.uuid}...")
remoteCharacteristic.subscribe()
.onEach { newValue ->
Timber.i("Value of ${remoteCharacteristic.uuid} changed: 0x${newValue.toHexString()}")
}
.onEmpty {
Timber.w("No updates from ${remoteCharacteristic.uuid}")
}
.onCompletion {
Timber.d("Stopped observing updates from ${remoteCharacteristic.uuid}")
}
.launchIn(scope)
} catch (e: Exception) {
// An exception is thrown when a characteristic does not have NOTIFY or INDICATE property,
// has no Client Characteristic Configuration descriptor, or an error occurs.
Timber.e("Failed to subscribe to ${remoteCharacteristic.uuid}: ${e.message}")
}
}For more, see sample.