Kotlin Multiplatform BLE library for iOS, Android, macos, windows and javascript
This standalone common Kotlin example hosts a small GATT echo server with the production
BlueFalconPeripheral API. It demonstrates application-owned lifecycle, explicit ATT request
routing, and notification backpressure through QueuePlugin.
The current peripheral backends support Android, iOS, and macOS. Other Blue Falcon targets can add peripheral backends later; this example does not claim JVM server support.
Use matching versions of the peripheral module and queue plugin in commonMain:
commonMain.dependencies {
implementation("dev.bluefalcon:blue-falcon-peripheral:<blue-falcon-version>")
implementation("dev.bluefalcon:blue-falcon-plugin-queue:<blue-falcon-version>")
}
Copy src/PeripheralEchoServer.kt into your common source set, then
create exactly one server for each application-owned peripheral manager:
import dev.bluefalcon.example.peripheral.PeripheralEchoServer
import dev.bluefalcon.peripheral.android.createBlueFalconPeripheral
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
// Android application startup
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val peripheral = createBlueFalconPeripheral(applicationContext)
val server = PeripheralEchoServer(peripheral, applicationScope)
applicationScope.launch {
server.start()
}
import dev.bluefalcon.example.peripheral.PeripheralEchoServer
import dev.bluefalcon.peripheral.apple.createBlueFalconPeripheral
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
// iOS/macOS application startup
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val peripheral = createBlueFalconPeripheral()
val server = PeripheralEchoServer(peripheral, applicationScope)
applicationScope.launch(start = CoroutineStart.UNDISPATCHED) {
server.start()
}
For Android 11 (API 30) and lower, declare the legacy BLUETOOTH and BLUETOOTH_ADMIN
permissions for the peripheral role. For Android 12 (API 31) and higher, declare and request
BLUETOOTH_ADVERTISE and BLUETOOTH_CONNECT at runtime. BLUETOOTH_SCAN and location permissions
are central-scanning concerns rather than GATT-server requirements; whether scanning requires
location depends on the Android version and how the application declares its scan usage.
Create the manager with the application context. If the server must remain available while the app is not visible, own its lifecycle from a foreground service rather than a screen or short-lived view model. That paragraph is architectural guidance, not a complete manifest recipe: the target SDK and current Android platform rules determine the required connected-device foreground-service type, permissions, and background-start eligibility.
Create the peripheral manager and launch server.start() during
application(_:didFinishLaunchingWithOptions:) or equivalent early application startup.
Constructing the factory alone is insufficient: the CoreBluetooth peripheral manager and its
restoration options are opened by start(). CoreBluetooth restoration requires this to happen
immediately with the same stable restoration identifier:
const val restorationIdentifier = "dev.bluefalcon.example.echo-peripheral"
CoroutineStart.UNDISPATCHED begins server.start() inline on the application-owned main scope,
reaching CoreBluetooth initialization before the first suspension and before the startup callback
returns. The scope must remain alive for the server’s lifetime.
Do not wait for lazy UI or view-model initialization when restoration is required. Keep the
application-owned manager, scope, and PeripheralEchoServer alive for the application’s BLE
lifetime on both iOS and macOS.
The server advertises as Blue Falcon Echo and exposes:
84f7e120-63fd-4f79-8b08-5b9780a36a9484f7e121-63fd-4f79-8b08-5b9780a36a94Hello from Blue FalconUsing a BLE client:
Blue Falcon Echo and discover the service and characteristic above.server.notifySubscribers(payload).notifySubscribers returns one typed result per subscribed session:
QueueSendResult.SentQueueSendResult.QueueFullQueueSendResult.PayloadTooLargeQueueSendResult.DisconnectedQueueSendResult.UnsupportedQueueSendResult.Failed(cause)Sent means the local platform or its bounded queue accepted/sent the update. It is not an
application-level acknowledgement from the remote peer.
The server snapshots the currently subscribed sessions and enqueues for them concurrently, so one slow peer does not prevent the other peers from being offered the payload.
start() can follow stop(); stopping advertising and the GATT server is restartable. close() is
terminal and idempotent: it cancels the request collector and closes the peripheral manager. Calls
to start() or stop() after close() are rejected; create a new manager and server instead.
The caller-provided scope must be active at construction and must outlive the server. Call
server.close() before cancelling applicationScope. Cancelling the scope stops request routing,
and a later start() is rejected rather than advertising without a request handler.
Lifecycle operations are serialized. notifySubscribers does not hold the lifecycle lock; sends
racing with close() complete with the queue’s typed result, including Disconnected or
Failed(cause).
QueuePlugin provides bounded per-session queuing and platform-readiness handling. It intentionally
does not implement application-layer fragmentation, acknowledgements, retries, or persistence.
The stored echo attribute is limited to 512 bytes, and larger writes receive
InvalidAttributeValueLength without changing the stored value. This attribute limit is separate
from notifications: payloads passed to notifySubscribers must also fit each individual session’s
maximumUpdateValueLength; larger notification payloads produce PayloadTooLarge.
The Compose Multiplatform example permanently includes this tutorial’s src and test directories
in its commonTest source set. This keeps the tutorial out of every production source set while
making its tests part of the normal shared-module test lifecycle:
cd ../ComposeMultiplatform-3.0-Example
./gradlew :shared:jvmTest --tests '*PeripheralEchoServerTest'