DEV Community

Cover image for I built a cross-platform BLE discovery layer for iOS and Android — here's how it works
Ran Engel
Ran Engel

Posted on

I built a cross-platform BLE discovery layer for iOS and Android — here's how it works

NearbyDiscoveryKit is a thin, app-agnostic framework that handles proximity discovery and payload handoff over Bluetooth so you don't have to.

Most apps that need "find someone nearby" don't actually need Bluetooth for very long. They need it for about three seconds — long enough to discover a nearby device, exchange a small piece of data, and hand control back to the app.

That narrow job is surprisingly hard to do cleanly across iOS and Android. CoreBluetooth and the Android BLE APIs are both capable but verbose, stateful, and full of edge cases. Every app ends up reimplementing the same scan → connect → exchange → disconnect cycle from scratch.

We got tired of doing that. So we extracted it into a reusable layer called NearbyDiscoveryKit.


The mental model

Before writing any code, we defined exactly what the framework should do:

Find a nearby device, briefly connect, exchange a small payload, and get out of the way.

That's it. The framework doesn't know what your payload means. It doesn't run a persistent session. It doesn't replace your backend. It just does the BLE part and hands the result to your app.

Two roles, one flow:

Host                              Client
  |                                  |
  | ← BLE advertisement ──────────── |
  | ← connect request ─────────────  |
  | ─── handoff payload ──────────→  |
  | ─── disconnect ───────────────→  |
Enter fullscreen mode Exit fullscreen mode

After the handoff the client has whatever the host put in the payload — a room code, a session ID, a backend URL, an invite token. From that point on, your app takes over.


The protocol

For iOS and Android to interoperate, they have to speak the same language at the BLE layer. We defined a shared protocol with three things:

Fixed UUIDs — both platforms advertise and scan for the same service UUID:

Service:        A1B2C3D4-E5F6-7890-ABCD-EF1234567890
Characteristic: B2C3D4E5-F6A7-8901-BCDE-F12345678901
Enter fullscreen mode Exit fullscreen mode

A JSON message envelope — all messages are UTF-8 JSON, max 512 bytes:

{
  "type": "handoff",
  "protocolVersion": 1,
  "appId": "com.example.myapp",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "payload": {
    "roomCode": "SWIFT42",
    "backendUrl": "https://api.example.com/room/SWIFT42"
  }
}
Enter fullscreen mode Exit fullscreen mode

Three message types:

  • join_request — sent by the client after subscribing to notifications
  • handoff — sent by the host in response, contains the payload
  • reject — reserved for future use

The key design decision here was to keep the envelope strictly typed but the payload completely open. The framework validates protocolVersion and appId — if they don't match, the connection is rejected before any payload is exchanged. What's inside payload is entirely up to the app.


The iOS implementation

The public API surface is intentionally small:

let client = NearbyDiscoveryClient()

client.onEvent = { event in
    switch event {
    case .payloadReceived(let message):
        let roomCode = message.payload?["roomCode"] ?? ""
        // hand off to your backend
    case .stateChanged(let state):
        print(state)  // "scanning", "connecting", "connected"
    case .error(let error):
        print(error)
    default:
        break
    }
}

// Join a nearby session
client.startScanning(appId: "com.example.myapp")

// Or host one
client.startHosting(
    appId:     "com.example.myapp",
    sessionId: UUID().uuidString,
    payload:   ["roomCode": "SWIFT42"]
)
Enter fullscreen mode Exit fullscreen mode

Under the hood, NearbyDiscoveryClient delegates to NDKAdvertiser (host path, built on CBPeripheralManager) and NDKScanner (client path, built on CBCentralManager). The GATT sequence — service setup, advertising, characteristic write, notification, disconnect — is all handled internally. The app never touches CoreBluetooth directly.

One subtlety worth calling out: the scanner subscribes to characteristic notifications before writing the join request. This ensures it doesn't miss the handoff response if the host replies faster than expected.


The Android implementation

The Kotlin API mirrors the Swift one exactly:

val client = NearbyDiscoveryClient(context)

client.onEvent = { event ->
    when (event) {
        is NDKEvent.PayloadReceived -> {
            val roomCode = event.message.payload?.get("roomCode")
            // hand off to your backend
        }
        is NDKEvent.StateChanged -> println(event.state)
        is NDKEvent.Error        -> println(event.message)
        else -> Unit
    }
}

// Join
client.startScanning(appId = "com.example.myapp")

// Or host
client.startHosting(
    appId     = "com.example.myapp",
    sessionId = UUID.randomUUID().toString(),
    payload   = mapOf("roomCode" to "KOTLIN42")
)
Enter fullscreen mode Exit fullscreen mode

We were deliberate about keeping the API symmetric. If someone reads the iOS docs and then picks up the Android library, the concepts map directly. Same event names, same lifecycle, same payload shape.


The emulator problem

Here's where it got interesting.

Android emulators don't have Bluetooth hardware. When you're developing an Android app that uses BLE, you're stuck — you either test only on physical devices, or you build some kind of mock layer.

We didn't want to ship a mock. Mocks test your mock, not your code. We wanted the Android implementation to run real BLE operations, just... bridged to hardware that the emulator can't access directly.

So we built BLEForEmulator — a small Mac menu bar app that acts as a BLE proxy. The Android emulator connects to it over TCP (10.0.2.2:7788, the standard emulator-to-host address). The Mac app translates those TCP commands into real CoreBluetooth calls on the Mac side.

Android Emulator ──TCP──→ BLEForEmulator Mac App ──CoreBluetooth──→ iOS device
Enter fullscreen mode Exit fullscreen mode

The Android library detects whether it's running on an emulator and automatically routes through the bridge if so:

class NearbyDiscoveryClient(private val context: Context) {
    val usingBridge: Boolean = EmulatorDetector.isEmulator()

    fun startScanning(appId: String) {
        val scanner: NDKDiscovery = if (usingBridge)
            NDKBridgeScanner(context, appId)   // TCP → Mac → CoreBluetooth
        else
            NDKScanner(context, appId)          // real BLE
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The same NearbyDiscoveryClient API works on both paths. On a physical Android device, it uses the Android BLE stack directly. On an emulator, it goes through the bridge — and your app code doesn't change.

We confirmed both paths work: iOS host → Android join, and Android host → iOS join.


What we learned

Keep BLE sessions short. Every second a BLE connection stays open is a second where something can go wrong. Our sessions last as long as it takes to exchange one message and disconnect — typically under two seconds. Reliability went up dramatically when we stopped trying to keep connections alive.

Shared protocol docs prevent subtle bugs. Before we wrote shared/protocol.json and docs/protocol.md, the iOS and Android implementations had drifted slightly in how they serialized certain fields. A shared canonical reference fixed that quickly and made it easy to add cross-platform JSON compatibility tests.

Symmetric APIs are worth the extra effort. It would have been faster to just write whatever felt natural in each language. But having the iOS and Android APIs mirror each other has real value — someone who knows one can pick up the other, and the mental model of "host/join/payload" transfers completely.


What's next

A few things are stubbed with TODOs in the current codebase:

  • MTU chunking — messages close to 512 bytes may get truncated before negotiation. Not an issue in practice for typical payloads, but needs fixing.
  • RSSI filtering — the scanner connects to the first host it finds regardless of signal strength. A threshold would enforce physical proximity.
  • Reject envelope — the host currently sends a raw GATT error on rejection. It should send a proper reject message so the client can handle it cleanly.

Try it

The framework is MIT-licensed and on GitHub:

👉 github.com/engelon/NearbyDiscoveryKit

The repo includes a TypeRaceDemo app for both iOS and Android that shows host and join screens wired up to the framework. If you have an iOS device and an Android emulator with BLEForEmulator running on your Mac, you can run a full cross-platform handoff in under five minutes.

If you find it useful or hit any issues, open an issue or a PR. The protocol is versioned so the plan is to keep the API stable from here.

Top comments (0)