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 ββββββββββββββββ |
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
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"
}
}
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"]
)
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")
)
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
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
// ...
}
}
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
rejectmessage 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.












