<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ran Engel</title>
    <description>The latest articles on DEV Community by Ran Engel (@engelon).</description>
    <link>https://dev.to/engelon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995657%2F5a90b243-d253-4b68-8d93-21dd91950ea3.png</url>
      <title>DEV Community: Ran Engel</title>
      <link>https://dev.to/engelon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/engelon"/>
    <language>en</language>
    <item>
      <title>I built a cross-platform BLE discovery layer for iOS and Android — here's how it works</title>
      <dc:creator>Ran Engel</dc:creator>
      <pubDate>Mon, 22 Jun 2026 21:07:35 +0000</pubDate>
      <link>https://dev.to/engelon/i-built-a-cross-platform-ble-discovery-layer-for-ios-and-android-heres-how-it-works-4e8p</link>
      <guid>https://dev.to/engelon/i-built-a-cross-platform-ble-discovery-layer-for-ios-and-android-heres-how-it-works-4e8p</guid>
      <description>&lt;p&gt;NearbyDiscoveryKit is a thin, app-agnostic framework that handles proximity discovery and payload handoff over Bluetooth so you don't have to.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;We got tired of doing that. So we extracted it into a reusable layer called &lt;strong&gt;NearbyDiscoveryKit&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Before writing any code, we defined exactly what the framework should do:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find a nearby device, briefly connect, exchange a small payload, and get out of the way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two roles, one flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host                              Client
  |                                  |
  | ← BLE advertisement ──────────── |
  | ← connect request ─────────────  |
  | ─── handoff payload ──────────→  |
  | ─── disconnect ───────────────→  |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The protocol
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed UUIDs&lt;/strong&gt; — both platforms advertise and scan for the same service UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;A1B2C3D4-E5F6-7890-ABCD-EF1234567890&lt;/span&gt;
&lt;span class="py"&gt;Characteristic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B2C3D4E5-F6A7-8901-BCDE-F12345678901&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A JSON message envelope&lt;/strong&gt; — all messages are UTF-8 JSON, max 512 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"handoff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.example.myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"550e8400-e29b-41d4-a716-446655440000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"roomCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SWIFT42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"backendUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com/room/SWIFT42"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Three message types:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;join_request&lt;/code&gt; — sent by the client after subscribing to notifications&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handoff&lt;/code&gt; — sent by the host in response, contains the payload&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reject&lt;/code&gt; — reserved for future use&lt;/li&gt;
&lt;/ul&gt;

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




&lt;h2&gt;
  
  
  The iOS implementation
&lt;/h2&gt;

&lt;p&gt;The public API surface is intentionally small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NearbyDiscoveryClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;payloadReceived&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;roomCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;?[&lt;/span&gt;&lt;span class="s"&gt;"roomCode"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
        &lt;span class="c1"&gt;// hand off to your backend&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stateChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "scanning", "connecting", "connected"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Join a nearby session&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startScanning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.example.myapp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Or host one&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startHosting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;"com.example.myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuidString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"roomCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"SWIFT42"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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




&lt;h2&gt;
  
  
  The Android implementation
&lt;/h2&gt;

&lt;p&gt;The Kotlin API mirrors the Swift one exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NearbyDiscoveryClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onEvent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;NDKEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PayloadReceived&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;roomCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"roomCode"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;// hand off to your backend&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;NDKEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StateChanged&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;NDKEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Error&lt;/span&gt;        &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Join&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startScanning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.example.myapp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Or host&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startHosting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;appId&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.example.myapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"roomCode"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="s"&gt;"KOTLIN42"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The emulator problem
&lt;/h2&gt;

&lt;p&gt;Here's where it got interesting.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So we built &lt;a href="https://github.com/engelon/BLEForEmulator" rel="noopener noreferrer"&gt;BLEForEmulator&lt;/a&gt; — a small Mac menu bar app that acts as a BLE proxy. The Android emulator connects to it over TCP (&lt;code&gt;10.0.2.2:7788&lt;/code&gt;, the standard emulator-to-host address). The Mac app translates those TCP commands into real CoreBluetooth calls on the Mac side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android Emulator ──TCP──→ BLEForEmulator Mac App ──CoreBluetooth──→ iOS device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Android library detects whether it's running on an emulator and automatically routes through the bridge if so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NearbyDiscoveryClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;usingBridge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EmulatorDetector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmulator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startScanning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NDKDiscovery&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usingBridge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nc"&gt;NDKBridgeScanner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// TCP → Mac → CoreBluetooth&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;
            &lt;span class="nc"&gt;NDKScanner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// real BLE&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;NearbyDiscoveryClient&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;We confirmed both paths work: iOS host → Android join, and Android host → iOS join.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep BLE sessions short.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared protocol docs prevent subtle bugs.&lt;/strong&gt; Before we wrote &lt;code&gt;shared/protocol.json&lt;/code&gt; and &lt;code&gt;docs/protocol.md&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symmetric APIs are worth the extra effort.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A few things are stubbed with TODOs in the current codebase:&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The framework is MIT-licensed and on GitHub:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/engelon/NearbyDiscoveryKit" rel="noopener noreferrer"&gt;github.com/engelon/NearbyDiscoveryKit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>android</category>
      <category>opensource</category>
      <category>bluetooth</category>
    </item>
    <item>
      <title>How I Gave the Android Emulator Real Bluetooth</title>
      <dc:creator>Ran Engel</dc:creator>
      <pubDate>Sun, 21 Jun 2026 19:04:47 +0000</pubDate>
      <link>https://dev.to/engelon/how-i-gave-the-android-emulator-real-bluetooth-1jbc</link>
      <guid>https://dev.to/engelon/how-i-gave-the-android-emulator-real-bluetooth-1jbc</guid>
      <description>&lt;p&gt;I was building a proximity-based app — the kind where two phones discover each other over BLE, exchange a small payload, and start a session together. Think of it like AirDrop, but for your own app.&lt;/p&gt;

&lt;p&gt;The iOS side was easy. The Android side was going well too, until I tried to test it.&lt;/p&gt;

&lt;p&gt;The Android Emulator doesn't support Bluetooth.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Wall
&lt;/h2&gt;

&lt;p&gt;Not "limited support." Not "experimental." Just nothing. You run a BLE scan on an emulator and it sits there, silent, finding zero devices, forever.&lt;/p&gt;

&lt;p&gt;The standard answer is: use a physical device. Which is fine until you're iterating fast, running on a Mac without a USB cable handy, or trying to test a cross-platform handshake between iOS and Android at the same time.&lt;/p&gt;

&lt;p&gt;There are workarounds. One involves a USB Bluetooth dongle, a full Python Bluetooth stack called Bumble, sudo access, and a specific Android API level. I spent an afternoon with it. It works, but it's not something you'd wish on anyone.&lt;/p&gt;

&lt;p&gt;I wanted something that just worked.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Idea
&lt;/h2&gt;

&lt;p&gt;The emulator can't do Bluetooth. But my Mac can. And the emulator can reach my Mac over TCP at &lt;code&gt;10.0.2.2&lt;/code&gt; — the standard emulator host IP.&lt;/p&gt;

&lt;p&gt;What if I wrote a small macOS app that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs a TCP server&lt;/li&gt;
&lt;li&gt;Listens for BLE commands from the emulator&lt;/li&gt;
&lt;li&gt;Performs those commands using the Mac's real Bluetooth radio via CoreBluetooth&lt;/li&gt;
&lt;li&gt;Sends the results back over the same connection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on the Android side, a library that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detects when it's running in an emulator&lt;/li&gt;
&lt;li&gt;Replaces the BLE transport with TCP calls to the Mac&lt;/li&gt;
&lt;li&gt;Gets out of the way entirely on real devices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No configuration. No USB. No Python. Just a menu bar app and one Gradle dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building It
&lt;/h2&gt;

&lt;p&gt;The protocol came first. I designed a simple JSON format — one object per line, newline-delimited — that mirrors the standard BLE vocabulary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startScan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serviceUuids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"A1B2C3D4-..."&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"advertisementFound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2AD28C65-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rssi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-62&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2AD28C65-..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"connectionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"conn-abc123"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commands flow from Android to Mac. Events flow back. Binary values are base64. The whole thing fits in a single JSON spec file.&lt;/p&gt;

&lt;p&gt;The Mac app is a SwiftUI menu bar app using &lt;code&gt;MenuBarExtra&lt;/code&gt; (macOS 13+). It runs an &lt;code&gt;NWListener&lt;/code&gt; TCP server and owns two CoreBluetooth proxies — one &lt;code&gt;CBCentralManager&lt;/code&gt; for scanning and connecting, one &lt;code&gt;CBPeripheralManager&lt;/code&gt; for advertising. Each connected emulator gets its own session that routes commands to the right proxy and forwards delegate callbacks back as events.&lt;/p&gt;

&lt;p&gt;The Android library wraps the whole thing in a &lt;code&gt;BLEBridge&lt;/code&gt; class with a callback-based API. Emulator detection uses &lt;code&gt;Build.FINGERPRINT&lt;/code&gt; — if it contains "generic" or "emulator", you're on an AVD.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bugs That Taught Me Things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Port conflict.&lt;/strong&gt; I originally used port 7788. Turns out the Android emulator's &lt;code&gt;netsimd&lt;/code&gt; daemon already uses 7788 for its own internal gRPC BLE simulation protocol. Multiple phantom connections were hitting my bridge before the Android app even started. Moved to 7877, problem gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bluetooth not ready.&lt;/strong&gt; CoreBluetooth initializes asynchronously. If a &lt;code&gt;startScan&lt;/code&gt; command arrives before &lt;code&gt;CBCentralManager&lt;/code&gt; reports &lt;code&gt;.poweredOn&lt;/code&gt;, it silently fails. The fix: queue the pending scan and replay it when the state update fires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Premature service discovery.&lt;/strong&gt; &lt;code&gt;didDiscoverServices&lt;/code&gt; fires before characteristics are fetched. I was emitting &lt;code&gt;servicesDiscovered&lt;/code&gt; too early — the Android side would call &lt;code&gt;writeCharacteristic&lt;/code&gt;, the Mac would look up the characteristic, find nothing, and silently no-op. Fixed by always waiting for &lt;code&gt;didDiscoverCharacteristicsFor&lt;/code&gt; before emitting the event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App Sandbox.&lt;/strong&gt; Xcode projects get App Sandbox enabled by default. With sandbox on, both TCP listening and Bluetooth are blocked at the OS level — silently, with no useful error message. Removing it from Signing &amp;amp; Capabilities fixed everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;End-to-end, it works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Mac bridge is running in the menu bar&lt;/li&gt;
&lt;li&gt;Android emulator starts, &lt;code&gt;BLEBridge&lt;/code&gt; connects over TCP&lt;/li&gt;
&lt;li&gt;Bridge sends &lt;code&gt;bridgeReady&lt;/code&gt; — Android starts scanning&lt;/li&gt;
&lt;li&gt;Mac scans via CoreBluetooth, finds an iPhone running in host mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;advertisementFound&lt;/code&gt; sent to Android&lt;/li&gt;
&lt;li&gt;Android connects, discovers services, writes a join request&lt;/li&gt;
&lt;li&gt;iPhone receives the write, sends a handoff notification&lt;/li&gt;
&lt;li&gt;Android receives the payload&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both directions work — the emulator can scan for real peripherals, or advertise and receive connections from them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mac:&lt;/strong&gt; Download &lt;code&gt;BLEForEmulator.zip&lt;/code&gt; from &lt;a href="https://github.com/engelon/BLEForEmulator/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;, unzip, move to /Applications, open it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android:&lt;/strong&gt; Add to your &lt;code&gt;settings.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;maven&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://jitpack.io"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And to your app's &lt;code&gt;build.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;debugImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.github.engelon:BLEForEmulator:v0.1.1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bridge&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BLEBridge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onEvent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;BridgeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BridgeReady&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MY_SERVICE_UUID&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;BridgeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AdvertisementFound&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full source, protocol spec, and docs are at &lt;a href="https://github.com/engelon/BLEForEmulator" rel="noopener noreferrer"&gt;github.com/engelon/BLEForEmulator&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;It's a development tool — not for production, use &lt;code&gt;debugImplementation&lt;/code&gt;. But for fast iteration on BLE features without reaching for a physical device every time, it does exactly what I needed.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ios</category>
      <category>bluetooth</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
