Blue-Falcon

Kotlin Multiplatform BLE library for iOS, Android, macos, windows and javascript

View the Project on GitHub Reedyuk/blue-falcon

Peripheral Echo Server Example

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.

Dependencies

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()
}

Platform startup and ownership

Android

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.

Apple

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.

Exercise the server

The server advertises as Blue Falcon Echo and exposes:

Using a BLE client:

  1. Connect to Blue Falcon Echo and discover the service and characteristic above.
  2. Write bytes to the characteristic. A non-zero offset overlays the current value.
  3. Read the characteristic to receive the updated echo value.
  4. Subscribe to notifications or indications.
  5. From the application, call server.notifySubscribers(payload).

notifySubscribers returns one typed result per subscribed session:

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.

Lifecycle and transport boundary

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.

Verification

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'