Build a Bluetooth chat for iOS and Android
Walk through the Bluetooth chat in bare-mobile-doctor: two phones find each other over Bluetooth Low Energy and trade messages over an L2CAP channel.
This guide walks through a working Bluetooth chat between two phones, with no network in between. The reference implementation is the Bluetooth screen of bare-mobile-doctor, Holepunch's diagnostics app for Bare on mobile: an Expo app in which every Bluetooth call runs in a Bare worklet through bare-bluetooth, and the React Native screen only draws what the worklet reports.
Every phone can advertise and scan at the same time: the screen has a Discoverable switch and a Scan switch, and either phone can invite the other. You tap a nearby phone to invite it, the other side accepts, and from then on messages travel over an L2CAP channel between the two.
bare-bluetooth is experimental upstream, so its API may change. This guide follows bare-mobile-doctor at commit 8639c02, which depends on bare-bluetooth ^0.6.2.
Before you begin
- Two physical phones, iOS or Android in any mix. Simulators and emulators can't stand in for the radio, and upstream warns that emulated Android devices can crash under QEMU.
- Android 12 (API level 31) or later: the app sets
minSdkVersionto 31. - The toolchain for the platforms you build: Xcode for iOS, and Android Studio with the SDK, NDK and JDK versions listed in the upstream README.
- The
patchelfbinary, which upstream's README asks for beforenpm install. - Familiarity with running Bare in a React Native app, covered in Embed Bare in a React Native app.
Clone and run
git clone https://github.com/holepunchto/bare-mobile-doctor
cd bare-mobile-doctor
npm install
npm run link
npm run bundlenpm run link links the native addons the worklets load, bare-bluetooth among them, into react-native-bare-kit; run it again after every reinstall. npm run bundle packs every worklet, including the Bluetooth one. If your npm config has ignore-scripts=true, @shopify/react-native-skia needs its install script run by hand; upstream's README has the command. Then build and install on each phone:
npm run ios
npm run androidnpm run ios is expo run:ios, which offers simulators as well as devices, so pick your iPhone. Installing on a physical iPhone needs a development team and provisioning profile; upstream's iOS release build steps walk through them in Xcode. For Android, upstream's README also lists a Gradle wrapper downgrade to apply after expo prebuild, without which the Android build can fail.
For two Android phones, upstream recommends a release build, which doesn't need a Metro dev server per device:
npx expo run:android --variant release --no-bundlerThe release APK can then be installed on the second phone without rebuilding:
adb -s <serial> install -r android/app/build/outputs/apk/release/app-release.apkOn both phones, open the Bluetooth screen and allow Bluetooth access when asked. Then:
- Turn on Discoverable on one phone. It starts advertising.
- Turn on Scan on the other. The first phone appears in the device list.
- Tap Invite next to it. The first phone shows the invite; tap Accept.
- Type messages on either side. Disconnect ends the chat on both.
Map the code
| Path | What it is |
|---|---|
bare/bluetooth.js | The worklet. Owns both Bluetooth roles, the L2CAP link and the chat protocol. This is where the Bluetooth code lives. |
app/views/bluetooth/BluetoothTests.tsx | The screen. Requests the Android permissions, starts the worklet and relays its messages to the UI. |
app.json | The iOS usage description and the Android permissions. |
scripts/link.mjs | Links the worklets' native addons. |
package.json gen:bluetooth | Packs bare/bluetooth.js into app/views/bluetooth/bluetooth.bundle.js for every iOS and Android target. |
The platform setup (permissions, linking, bundling) is the same for any Bluetooth app and has its own guide: Set up Bluetooth in a mobile app. The rest of this page is about bare/bluetooth.js.
How a chat starts
The worklet builds three objects: BLECentral scans and connects, BLEServer advertises and accepts, and Session runs the invite and chat protocol on top of whichever link opens first. Both phones run all three, so either one can invite the other:
const ipc = new FramedStream(BareKit.IPC)
const central = new BLECentral({ serviceUUID: SERVICE_UUID, psmUUID: PSM_UUID })
const server = new BLEServer({ serviceUUID: SERVICE_UUID, psmUUID: PSM_UUID })
const session = new Session({
central,
server,
deviceName: Bare.argv[0] || 'BareDevice',
send: (msg) => ipc.write(Buffer.from(JSON.stringify(msg)))
})Here is what happens between the inviting phone (the central) and the invited one (the server):
1. Wait for the radio
Nothing works until the adapter reports 'poweredOn': startScan() throws before then, and turning Bluetooth off drops the services a server has added. The central re-emits the state, and starts a scan the user asked for while the radio was still off:
this._central.on('stateChange', (bleState) => {
dbg('Central: stateChange', bleState)
this.emit('stateChange', bleState)
if (bleState === 'poweredOn') {
this.emit('ready')
if (this.scanning === ToggleState.REQUESTED) this.startScan()
}
})The server does its setup in _start(), which it calls from its constructor and again on every 'poweredOn':
_start() {
if (this._server.state !== 'poweredOn') return
if (!this.serviceAdded) {
this._server.addService(new Service(this.serviceUUID, [this.psmChar]))
}
if (this._psm == null) this._server.publishChannel()
}This sample has a gap. _start() skips addService() once serviceAdded is true and skips publishChannel() once it holds a PSM, and nothing resets either flag when the radio goes off. After a Bluetooth toggle, an Android phone (where bare-bluetooth closes the GATT server) would advertise with no service and a stale PSM. If your app has to survive that, clear both when the state leaves 'poweredOn', as Bluetooth best practices describes.
2. Publish a channel and serve its PSM
An L2CAP channel is identified by a PSM number that the operating system picks when you publish it, so the other phone can't know it in advance. The server solves that with a GATT service holding one readable characteristic, PSM_UUID, and answers every read of it with the PSM as text:
const SERVICE_UUID = 'B4A3C8A7-0000-1000-8000-00805F9B34FB'
// Readable characteristic that carries the L2CAP PSM the server published, so a
// central can learn where to open the connection-oriented channel.
const PSM_UUID = 'B4A3C8A7-0003-1000-8000-00805F9B34FB'
const CONNECT_TIMEOUT_MS = 15000
const isAndroid = Bare.platform === 'android'
const scanOptions = isAndroid ? { scanMode: Central.SCAN_MODE_LOW_LATENCY } : undefined this._server.on('channelPublish', (psm) => {
dbg('Server: channelPublish psm=', psm)
this._psm = psm
this.emit('log', `L2CAP channel published psm=${psm}`)
if (this.advertising === ToggleState.REQUESTED) this.startAdvertising()
})
this._server.on('channelOpen', (channel) => {
dbg('Server: channelOpen (invitee side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})
// A central reads this to discover the PSM before opening the channel. The
// PSM is a plain number, so send it as raw text (not JSON) - the central
// parses it with Number().
this._server.on('readRequest', (req) => {
dbg('Server: readRequest (PSM) psm=', this._psm)
const value = this._psm != null ? Buffer.from(String(this._psm)) : Buffer.alloc(0)
this._server.respondToRequest(req, Server.ATT_SUCCESS, value)
})3. Advertise once both are ready
A central that connects before the service exists, or before there is a PSM to read, gets nothing. So startAdvertising() only advertises once serviceAdd and channelPublish have both fired; until then it records the request, and those two handlers call it again:
startAdvertising(deviceName) {
if (deviceName !== undefined) this._deviceName = deviceName
if (this.advertising === ToggleState.ON) return
if (this._server.state !== 'poweredOn') {
this.emit('log', 'Advertising is waiting for Bluetooth power')
return
}
// Both the GATT service (for PSM discovery) and the published PSM must be
// ready before we invite centrals to connect.
if (!this.serviceAdded || this._psm == null) {
this._start()
this.advertising = ToggleState.REQUESTED
this.emit('log', 'Advertising is waiting for service and PSM')
return
}
const opts = { serviceUUIDs: [this.serviceUUID] }
if (this._deviceName) opts.name = this._deviceName
this.emit('log', `Starting advertising: ${this.serviceUUID}`)
this._server.startAdvertising(opts)
this.advertising = ToggleState.ON
this.emit('advertisingStarted')
}The advertisement carries the service UUID, which is what the other phone filters its scan on.
4. Scan for the service
The central scans only for peripherals advertising the chat's service UUID. On Android it also passes the low-latency scan mode (the scanOptions constant in step 2), which is already bare-bluetooth-android's default, so it only makes the choice explicit:
startScan() {
if (this.scanning === ToggleState.ON) return
if (this._central.state !== 'poweredOn') {
this.emit('log', 'Scan is waiting for Bluetooth power')
return
}
this._central.startScan([this.serviceUUID], scanOptions)
this.scanning = ToggleState.ON
this.emit('scanStarted')
}Every advertisement arrives as a discover event, often many per phone. The central keeps the latest one per id and reports it to the UI:
this._central.on('discover', (discovered) => {
this.discoveredPeripherals.set(discovered.id, discovered)
let name = discovered.name
if (!name && discovered.serviceData && discovered.serviceData[this.serviceUUID]) {
name = Buffer.from(discovered.serviceData[this.serviceUUID]).toString()
}
this.emit('discovered', { id: discovered.id, name: name || 'Unknown', rssi: discovered.rssi })
})5. Connect, find the PSM and open the channel
Tapping Invite stops the scan and connects to the discovered peripheral:
connect(id) {
const discovered = this.discoveredPeripherals.get(id)
if (!discovered) {
this.emit('error', 'Device not found: ' + id)
return false
}
if (this.scanning !== ToggleState.OFF) this.stopScan()
this.emit('log', `Connecting to: ${discovered.name || discovered.id || 'unknown'}`)
this._central.connect(discovered)
return true
}Once connected, the central walks down to the PSM one event at a time: discover the service, discover its PSM characteristic, read it, and open the channel on the number it reads:
_onConnect(peripheral) {
this.peripheral = peripheral
this.emit('log', `Connected: ${peripheral.name || peripheral.id || 'unknown'}`)
peripheral.on('servicesDiscover', (services) => {
dbg('Central: servicesDiscover', services && services.length)
const service = findByUUID(services, this.serviceUUID)
if (!service) {
this.emit('error', 'Chat service not found')
this.emit('connectFailed')
return
}
peripheral.discoverCharacteristics(service, [this.psmUUID])
})
peripheral.on('characteristicsDiscover', (service, chars) => {
dbg('Central: characteristicsDiscover', chars && chars.length)
const psmChar = findByUUID(chars, this.psmUUID)
if (!psmChar) {
this.emit('error', 'PSM characteristic not found')
this.emit('connectFailed')
return
}
this.emit('log', 'Reading L2CAP PSM')
peripheral.read(psmChar)
})
peripheral.on('read', (char, data) => {
const text = Buffer.from(data).toString()
dbg('Central: read PSM char ->', JSON.stringify(text))
const psm = Number(text)
if (!psm) {
this.emit('error', `Invalid PSM: ${JSON.stringify(text)}`)
this.emit('connectFailed')
return
}
this.emit('log', `Opening L2CAP channel psm=${psm}`)
peripheral.openL2CAPChannel(psm)
})
peripheral.on('channelOpen', (channel) => {
dbg('Central: channelOpen (inviter side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})
peripheral.on('error', (err) => {
dbg('Central: peripheral error', err && err.message)
this.emit('error', err.message || String(err))
this.emit('connectFailed')
})
this.emit('log', 'Discovering services')
peripheral.discoverServices([this.serviceUUID])
}findByUUID() doesn't compare UUIDs with ===. iOS reports them uppercase, Android lowercase, so both sides are normalized first:
function normalizeUUID(uuid) {
return String(uuid || '')
.toLowerCase()
.replace(/-/g, '')
}
function matchesUUID(a, b) {
return normalizeUUID(a) === normalizeUUID(b)
}
function findByUUID(items, uuid) {
if (!items) return null
for (const item of items) {
if (matchesUUID(item.uuid, uuid)) return item
}
return null
}6. Chat over the channel
When the channel opens, both sides get an L2CAPChannel, a duplex stream, and from here on there is no central or server any more. Each wraps the channel in a PeerLink, which frames it with framed-stream so every write arrives as one whole message, and sends JSON:
// A framed, bidirectional message channel over an L2CAP Duplex. Both peers use
// the same object: once the channel is open there is no central/server
// asymmetry, so the chat protocol runs identically on either side.
class PeerLink extends EventEmitter {
constructor(channel) {
super()
dbg('PeerLink: new (wrapping L2CAP channel in FramedStream)')
this._stream = new FramedStream(channel)
this._stream.on('data', (data) => {
dbg('PeerLink: data', data.byteLength, 'bytes')
let msg
try {
msg = JSON.parse(Buffer.from(data).toString())
} catch (e) {
this.emit('error', `Bad BLE data (${data.byteLength} bytes)`)
return
}
this.emit('message', msg)
})
// L2CAP is reliable: a peer leaving surfaces as end/close/error on the
// stream, so we don't need platform-specific disconnect detection.
this._stream.on('close', () => {
dbg('PeerLink: stream close')
this.emit('close')
})
this._stream.on('error', (err) => {
dbg('PeerLink: stream error', err && err.message)
this.emit('close')
})
}
send(msg) {
dbg('PeerLink: send', msg.t)
this._stream.write(serialize(msg))
}
close() {
dbg('PeerLink: close()')
this._stream.destroy()
}
}The chat protocol is five messages, handled in Session.handlePeerMessage():
| Message | Sent by | Meaning |
|---|---|---|
{ t: 'invite', n } | inviter, as soon as the channel opens | "n wants to chat" |
{ t: 'accept' } | invitee | start the chat |
{ t: 'reject' } | invitee | decline; the inviter tears the link down |
{ t: 'msg', d } | either | a chat message with text d |
{ t: 'disconnect' } | either | the chat is over |
L2CAP is reliable, so a peer that closes the channel shows up as close or error on the stream, the same on both platforms. That's why PeerLink needs no platform-specific disconnect detection for that case. A peer that vanishes without closing the channel takes longer to notice; see Notice a peer that has vanished.
Handle failures
On iOS and Android, a failed connection attempt, and a disconnect caused by an error, arrive as an error event rather than a disconnect. The central routes those two codes back into the session, or it would stay stuck in "connecting":
this._central.on('error', (err) => {
dbg('Central: error', err && err.code, err && err.message)
this.emit('error', err.message || String(err))
// iOS & Android report errored connects/disconnects as 'error' (not
// 'disconnect'), so route the codes or the session stays stuck connected.
if (err && err.code === 'CONNECTION_FAILED') {
this.emit('connectFailed')
} else if (err && err.code === 'DISCONNECT') {
this.emit('closed')
}
})bare-bluetooth sets no timeout on a connection attempt either, so the session sets its own, 15 seconds, and goes back to scanning when it fires:
startConnectTimeout() {
this.clearConnectTimeout()
this.state.connectTimer = setTimeout(() => {
this.state.connectTimer = null
if (this.state.inviteRole !== 'inviter' || this.state.link) return
this.teardown()
this.send({ type: 'error', message: 'Connection timed out' })
this.send({ type: 'disconnected' })
this.central.setScan(true)
}, CONNECT_TIMEOUT_MS)
}This timer abandons the session, but it doesn't cancel the pending connection. BLECentral.disconnect() only acts once a peripheral has connected, so a late connect can still arrive after the timeout. In your own code, call central.disconnect() with the object you passed to connect(), the way the Apple test in Reconnect without scanning does.
Tear down
Ending a chat closes the stream and drops the Bluetooth connection, whichever role this phone played:
// Close the link and drop any BLE connection we opened. Safe to call from any
// role: central.disconnect() is a no-op when we never connected.
teardown() {
dbg('Session: teardown (role=' + this.state.inviteRole + ')')
if (this.state.link) {
this.state.link.close()
this.state.link = null
}
this.central.disconnect()
this.state.inviteRole = 'idle'
}When the user leaves the screen, the React Native side sends disconnect to the worklet so the other phone hears about it right away instead of waiting for the Bluetooth supervision timeout, then terminates the worklet a moment later:
return () => {
const stream = streamRef.current
const worklet = workletRef.current
streamRef.current = null
// Tell the peer we're leaving so it tears down cleanly instead of waiting
// for the BLE supervision timeout, then give the worklet a moment to write
// the disconnect over the air before terminating it.
try {
stream?.write(b4a.from(JSON.stringify({ type: 'disconnect' })))
} catch {}
setTimeout(() => worklet?.terminate(), 150)
}On exit, the worklet deliberately skips server.destroy() and central.destroy(). A comment in the code says that on iOS the native module's runtime-teardown cleanup frees the same handle a second time after an explicit destroy(), and crashes. bare-bluetooth-apple fixed that cleanup in July 2026, before the comment was written, so the workaround may be out of date. Test teardown on a device before you copy it or drop it; see Bluetooth best practices.
// We only release our own state here (connect timer + L2CAP link). We should
// also call server.destroy()/central.destroy() to own their teardown, but the
// native module's on_cleanup double-frees the same V8 handle at runtime
// teardown and crashes (EXC_BAD_ACCESS in central__on_cleanup).
// TODO: restore server.destroy()/central.destroy() once bare-bluetooth-apple
// makes on_cleanup idempotent w.r.t. an explicit destroy().
destroy() {
dbg('Session: destroy')
this.clearConnectTimeout()
if (this.state.link) {
this.state.link.close()
this.state.link = null
}
}Remix it
The channel is a plain duplex stream, so the chat is only one use of it:
| Build | Change |
|---|---|
| File or photo transfer | Keep PeerLink's framing and send binary frames instead of JSON. |
| A protocol of your own | Replace Session.handlePeerMessage() and the t tags; the discovery and channel code stays as it is. |
| Encrypted, replicating peers | Use ble-swarm instead, which runs a Noise handshake over each Bluetooth connection and hands you the same kind of encrypted stream as Hyperswarm, ready for corestore.replicate(). |
Where to go next
- Set up Bluetooth in a mobile app: the permissions and build steps this app depends on.
- Exchange data over L2CAP: the same channel from two laptops, with plain
bare. - Bluetooth best practices: why this code waits, normalizes and times out where it does.
bare-bluetoothreference: every class, event and constant.- Handle app suspension: what to do with the radio when the app goes to the background.
Last updated on