CAMDOM — Native Module Implementation (Kotlin + Swift)
Complete native BLE implementation: Android GATT Server, iOS CBPeripheralManager, Expo Modules bridge, packet encoding, background execution.
CAMDOM — Native Module Implementation
The BLE communication layer of CAMDOM is implemented as an Expo Modules native module (BleManager) with platform-specific Kotlin (Android) and Swift (iOS) implementations. The module exposes a GATT server (peripheral role) to JavaScript, while the client role (scanning + GATT client) is handled in TypeScript via react-native-ble-plx.
Table of Contents
- Architecture Overview
- GATT Service Architecture
- Android Native Layer (Kotlin)
- iOS Native Layer (Swift)
- TypeScript Bridge Layer
- Cross-Platform Abstraction
- Packet Encoding and Decoding
- Role Election Algorithm
- Background Execution
- Error Handling
- Application Entry Points
- Full Packet Protocol Reference
Architecture Overview
┌─────────────────────────────────────────────────────────┐
│ TypeScript Layer │
│ BleManager.ts (state machine, packet protocol, UI) │
│ │ │
│ ├── blePlxManager (react-native-ble-plx) │
│ │ └── Client role: scan, connect, read/write │
│ │ │
│ └── BleManagerModule (Expo Modules native bridge) │
│ └── Server role: advertise, GATT server, │
│ indicate to subscribed centrals │
├────────────────────┬────────────────────────────────────┤
│ Android (Kotlin) │ iOS (Swift) │
│ │ │
│ BleManagerModule │ BleManagerModule │
│ └─ PeripheralMgr │ └─ BlePeripheralManager (shared) │
│ ├─ GATT Server│ ├─ CBPeripheralManager │
│ ├─ Advertise │ ├─ CBMutableService │
│ └─ Indicate │ └─ updateValue (indicate) │
└────────────────────┴────────────────────────────────────┘
The native module is registered as "BleManager" on both platforms. It provides four exported functions:
| Function | Signature | Description |
|---|---|---|
bindServer | (name: string) → void | Sets the advertised local name and starts GATT server + advertising |
unbindServer | (stopServer: boolean) → void | Stops advertising; if stopServer=true, also tears down the GATT server |
broadcastPacket | (packet: string) → void | Sends a string as an indication to all subscribed centrals |
sendPacketToClient | (packet: string, to: string) → void | Sends a string as an indication to a specific central identified by address/UUID |
Eight events are emitted from native to JavaScript:
| Event | Payload | Description |
|---|---|---|
onStartGattServer | none | GATT service registered successfully |
onStopGattServer | none | GATT server closed |
onStartAdvertising | { advertisingName, success, errorCode?, errorMessage? } | Advertising started or failed |
onStopAdvertising | none | Advertising stopped |
onDeviceConnected | string (device address/MAC or central UUID) | Central connected to GATT server |
onDeviceDisconnected | string (device address/MAC or central UUID) | Central disconnected |
onNotificationSent | string | Confirmation that an indication was sent |
onPacketFromClient | { data: string, from: string } | Data received from a central on the write characteristic |
Additionally, Android's BleManager.kt (the older client-side module) emits:
| Event | Payload | Description |
|---|---|---|
onConnectedDeviceStateChange | { to: string, state: number } | Connection state changed for a tracked device |
onPacketFromClients | { value: string } | Legacy packet receive (older module) |
onPacketFromServer | { value: string } | Packet received from server via indicate |
onCentralDeviceConnected | none | Legacy: central connected to GATT server |
onCentralDeviceDisconnected | none | Legacy: central disconnected |
onRSSIUpdate | { value: string } | RSSI reading from connected peripheral |
onWriteError | { value: number } | Write characteristic error status |
fireAlarm | { value: string } | Server sent "alarm" indicate |
allowDisconnection | { value: string } | Server sent "disconnect" indicate |
GATT Service Architecture
UUIDs
All UUIDs share the base 25AE14__-05D3-4C5B-8281-93D4E07420CF with sequential bytes.
| Role | UUID | Properties (Server) | Permissions |
|---|---|---|---|
| Service | 25AE1441-05D3-4C5B-8281-93D4E07420CF | Primary Service | — |
| Read Characteristic | 25AE1442-05D3-4C5B-8281-93D4E07420CF | READ | PERMISSION_READ |
| Write Characteristic | 25AE1443-05D3-4C5B-8281-93D4E07420CF | WRITE | PERMISSION_WRITE |
| Indicate Characteristic | 25AE1444-05D3-4C5B-8281-93D4E07420CF | INDICATE | PERMISSION_READ |
| CCC Descriptor | 00002902-0000-1000-8000-00805f9b34fb | (standard CCC) | READ | WRITE |
How Each Characteristic Is Used
Read Characteristic (25AE1442)
The server responds to read requests with its current advertisingName (the local numeric identifier). Clients can read this to identify which server they connected to. On Android, the BlePeripheralManager returns advertisingName.toByteArray(Charsets.UTF_8). On iOS, it returns advertisedName.data(using: .utf8).
Write Characteristic (25AE1443)
This is the primary data channel from client to server. Clients write Base64-encoded packets here. The server decodes them and emits onPacketFromClient with { data: decodedString, from: deviceAddress }. The server always responds with GATT_SUCCESS / .success echoing back the value if responseNeeded is true.
On iOS, there is special handling: if the written value starts with "c:" (connection packet), the central is added to connectedCentrals and an onDeviceConnected event is emitted. This handles the case where a central connects without subscribing first.
Indicate Characteristic (25AE1444)
This is the primary data channel from server to clients. The server uses notifyCharacteristicChanged (Android) or updateValue(for:onSubscribedCentrals:) (iOS) to push data to subscribed centrals. Clients must subscribe by writing ENABLE_INDICATION_VALUE to the CCC descriptor.
Key difference from notify: Indicate requires acknowledgement from the client, making it reliable but slower.
Subscription Flow
- Client writes
ENABLE_INDICATION_VALUE(0x0100) to CCC descriptor of25AE1444 - Server's
onDescriptorWriteRequestadds device tosubscribedDevicesset (Android) orsubscribedCentralsarray (iOS) - Server can now push data via
broadcastPacketorsendPacketToClient - Client unsubscribes by writing
DISABLE_NOTIFICATION_VALUE(0x0000)
Android Native Layer (Kotlin)
There are two Android native classes. BlePeripheralManager is the production server implementation used by BleManagerModule. BleManager is the older client-side implementation that handles scanning, connecting, and GATT client operations.
BleManagerModule.kt
File: modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BleManagerModule.kt
Extends expo.modules.kotlin.modules.Module and implements expo.modules.core.interfaces.SingletonModule.
class BleManagerModule : Module(), SingletonModule {
private lateinit var peripheralManager: BlePeripheralManager
override fun getName(): String = "BleManager"
OnCreate: Instantiates BlePeripheralManager with the application context and a reference to itself (for event emission):
OnCreate {
peripheralManager = BlePeripheralManager(
BlePeripheralManagerOptions(
appContext.reactContext?.applicationContext!!,
this@BleManagerModule
)
)
}
Events registration: All 8 event names are registered in the Events(...) block, making them available to JavaScript's EventEmitter.
Exported functions:
bindServer(name: String)— Delegates toperipheralManager.updateAdvertisedName(name)unbindServer(stopServer: Boolean)— Delegates toperipheralManager.bleStopAdvertising(stopServer)broadcastPacket(packet: String)— Converts toByteArrayand delegates toperipheralManager.broadcastPacket(packet.toByteArray())sendPacketToClient(packet: String, to: String)— Converts toByteArrayand delegates toperipheralManager.sendPacketToClient(packet.toByteArray(), to)
BlePeripheralManager.kt
File: modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BlePeripheralManager.kt
The production GATT server implementation. This class manages the full peripheral lifecycle.
State
private var gattServer: BluetoothGattServer? = null
private val subscribedDevices = mutableSetOf<BluetoothDevice>()
private val connectedDevices = mutableSetOf<BluetoothDevice>()
private var advertisingName: String = ""
private var isAdvertising: Boolean = false
Two separate sets track subscribedDevices (centrals that wrote to the CCC descriptor on the indicate characteristic) and connectedDevices (centrals with an active GATT connection).
GATT Server Setup
bleStartGattServer() opens a GATT server via bluetoothManager.openGattServer(), creates a BluetoothGattService with SERVICE_TYPE_PRIMARY, and adds three characteristics:
val charForRead = BluetoothGattCharacteristic(
UUID.fromString(CHAR_FOR_READ_UUID),
BluetoothGattCharacteristic.PROPERTY_READ,
BluetoothGattCharacteristic.PERMISSION_READ
)
val charForWrite = BluetoothGattCharacteristic(
UUID.fromString(CHAR_FOR_WRITE_UUID),
BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_WRITE
)
val charForIndicate = BluetoothGattCharacteristic(
UUID.fromString(CHAR_FOR_INDICATE_UUID),
BluetoothGattCharacteristic.PROPERTY_INDICATE,
BluetoothGattCharacteristic.PERMISSION_READ
)
The indicate characteristic gets a CCC descriptor with READ|WRITE permissions. After adding the service, emitter.sendEvent("onStartGattServer") fires.
Advertising
Advertising configuration:
private val advertiseSettings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
private val advertiseData = AdvertiseData.Builder()
.setIncludeDeviceName(false) // name goes in scan response
.addServiceUuid(ParcelUuid(UUID.fromString(SERVICE_UUID)))
.build()
private val scanResponse: AdvertiseData = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.build()
The device name is excluded from the primary advertising packet because names larger than 8 bytes cause ADVERTISE_FAILED_DATA_TOO_LARGE. Instead, the name is placed in the scan response, which has a larger payload limit.
updateAdvertisedName(name) sets the Bluetooth adapter name and starts/restarts advertising. If already advertising, it stops first then restarts.
GATT Server Callback
The gattServerCallback handles all incoming requests from connected centrals:
onConnectionStateChange: Tracks connected/disconnected devices. Emits onDeviceConnected with the device's MAC address on connect, and onDeviceDisconnected on disconnect. Intermediate states emit onConnectedDeviceStateChange.
onCharacteristicReadRequest: For CHAR_FOR_READ_UUID, responds with advertisingName.toByteArray(Charsets.UTF_8) and GATT_SUCCESS. For unknown UUIDs, responds with GATT_FAILURE.
onCharacteristicWriteRequest: For CHAR_FOR_WRITE_UUID, decodes the value as UTF-8, emits onPacketFromClient with { data: strValue, from: device.address }, and responds with GATT_SUCCESS echoing the value. Unknown UUIDs get GATT_FAILURE.
onDescriptorReadRequest: For the CCC descriptor, returns ENABLE_NOTIFICATION_VALUE if the device is in subscribedDevices, otherwise DISABLE_NOTIFICATION_VALUE.
onDescriptorWriteRequest: For the CCC descriptor on the indicate characteristic:
ENABLE_INDICATION_VALUE→ adds device tosubscribedDevices, returnsGATT_SUCCESSDISABLE_NOTIFICATION_VALUE→ removes device fromsubscribedDevices, returnsGATT_SUCCESS- Anything else → returns
GATT_REQUEST_NOT_SUPPORTED
Broadcast and Targeted Send
fun broadcastPacket(value: ByteArray?) {
charForIndicate?.let {
for (device in subscribedDevices) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gattServer?.notifyCharacteristicChanged(device, it, false, value!!)
} else {
it.value = value
gattServer?.notifyCharacteristicChanged(device, it, false)
}
}
}
}
On Android 13+ (TIRAMISU / API 33), the value is passed directly as a parameter to notifyCharacteristicChanged. On older versions, the characteristic's .value property is set first, then notifyCharacteristicChanged is called without the value parameter (deprecated API).
sendPacketToClient has two overloads: one taking a BluetoothDevice object, one taking a MAC address string. The string variant iterates subscribedDevices and matches by device.address.equals(deviceMac).
Cleanup
fun bleStopGattServer() {
gattServer?.close()
gattServer = null
isAdvertising = false
advertisingName = ""
emitter.sendEvent("onStopGattServer")
}
BleManager.kt (Legacy Client Module)
File: modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BleManager.kt
This is the older implementation that handles both client and server roles on Android. It is not directly used by the current Expo Modules bridge but contains important logic.
UUIDs (Slightly Different)
Note: This file uses ALEF instead of 05D3 in the UUIDs:
private const val SERVICE_UUID = "25AE1441-ALEF-4C5B-8281-93D4E07420CF"
private const val CHAR_FOR_READ_UUID = "25AE1442-ALEF-4C5B-8281-93D4E07420CF"
private const val CHAR_FOR_WRITE_UUID = "25AE1443-ALEF-4C5B-8281-93D4E07420CF"
private const val CHAR_FOR_INDICATE_UUID = "25AE1444-ALEF-4C5B-8281-93D4E07420CF"
This appears to be a development artifact. The production BlePeripheralManager.kt uses the canonical 05D3 UUIDs.
Lifecycle State Machine
enum class BLELifecycleState {
Disconnected,
Scanning,
Connecting,
ConnectedDiscovering,
ConnectedSubscribing,
Connected
}
Scanning and Role Election
bleStartScanForPeripherals() starts a BLE scan with a ScanFilter for SERVICE_UUID only. Scan settings use SCAN_MODE_LOW_LATENCY with MATCH_MODE_AGGRESSIVE on API 23+.
The scanCallback.onScanResult implements the role election:
- Parse the scanned device's name as an integer (
nameInt) - If
localNumberis set (this device is advertising):- If
nameInt > localNumber: Stop advertising, stop scanning, connect to the remote device as a client - If
nameInt <= localNumber: Stop scanning, restart advertising with name "251" (forcing higher value)
- If
- If
localNumberis null: Stop advertising, stop scanning, connect to the remote device
GATT Client Callback
The gattCallback handles:
onConnectionStateChange: On STATE_CONNECTED, emits onDeviceConnected, posts gatt.discoverServices() to the main thread. On STATE_DISCONNECTED, emits onDeviceDisconnected, closes GATT.
onServicesDiscovered: Finds the CAMDOM service by UUID, extracts the three characteristics, and subscribes to indications on CHAR_FOR_INDICATE_UUID by writing ENABLE_INDICATION_VALUE to the CCC descriptor.
onCharacteristicChanged: Receives data on the indicate characteristic. Special-cases "alarm" → emits fireAlarm, and "disconnect" → emits allowDisconnection. Always emits onPacketFromServer.
onDescriptorWrite: After CCC descriptor write completes, transitions to BLELifecycleState.Connected.
Writing to Server
fun bleWrite(value: ByteArray) {
characteristicForWrite?.let {
connectedGatt?.writeCharacteristic(
it,
value,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
)
}
}
WRITE_TYPE_DEFAULT means write-with-response (acknowledged write).
iOS Native Layer (Swift)
BleManagerModule.swift
File: modules/ble-manager/ios/BleManagerModule.swift
public class BleManagerModule: Module {
private let peripheralManager: BlePeripheralManager = BlePeripheralManager.shared.self
Uses the singleton pattern via BlePeripheralManager.shared. On OnCreate, binds the emitter:
OnCreate {
peripheralManager.bindEmitter(module: self)
}
Events are registered identically to Android (same 8 event names, minus onConnectedDeviceStateChange which is Android-only).
Exported functions mirror Android exactly:
bindServer(name)→peripheralManager.startAdvertising(deviceName: name)unbindServer(stopServer)→peripheralManager.bleStopAdvertising(stopServer: stopServer)broadcastPacket(packet)→peripheralManager.broadcastPacket(packet)sendPacketToClient(packet, to)→peripheralManager.sendPacketToClient(packet, deviceId: to)
BlePeripheralManager.swift
File: modules/ble-manager/ios/BlePeripheralManager.swift
Implements CBPeripheralManagerDelegate and CBPeripheralDelegate. Uses a singleton pattern:
public static let shared: BlePeripheralManager = {
if _sharedInstance == nil {
_sharedInstance = BlePeripheralManager()
}
return _sharedInstance
}()
CoreBluetooth Initialization
private override init() {
super.init()
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
queue: nil means callbacks are dispatched on the main queue.
Service Building
private func buildBLEService() -> CBMutableService {
let charForRead = CBMutableCharacteristic(type: uuidCharForRead,
properties: .read,
value: nil,
permissions: .readable)
let charForWrite = CBMutableCharacteristic(type: uuidCharForWrite,
properties: .write,
value: nil,
permissions: .writeable)
let charForIndicate = CBMutableCharacteristic(type: uuidCharForIndicate,
properties: .indicate,
value: nil,
permissions: .readable)
self.charForIndicate = charForIndicate
let service = CBMutableService(type: uuidService, primary: true)
service.characteristics = [charForRead, charForWrite, charForIndicate]
return service
}
All characteristics are created with value: nil — values are provided dynamically at read/write time.
Advertising
func startAdvertising(deviceName: String) {
if isAdvertising { bleStopAdvertising(stopServer: false) }
if !isServicesBuilt { peripheralManager.add(buildBLEService()) }
self.advertisedName = deviceName
peripheralManager.startAdvertising([
CBAdvertisementDataLocalNameKey: deviceName,
CBAdvertisementDataServiceUUIDsKey: [uuidService]
])
isAdvertising = true
}
Key difference from Android: iOS includes the device name directly in the advertising data dictionary (CBAdvertisementDataLocalNameKey), while Android puts it in the scan response. This is because CBPeripheralManager does not have a separate scan response concept — the local name key handles both.
The peripheralManager.add(buildBLEService()) is idempotent only if isServicesBuilt is false. Services are added once and reused.
State Updates
peripheralManagerDidUpdateState(_:): When poweredOn, clears all state. When in any other state, clears state AND removes all services. This is a full reset on power-off.
peripheralManager(_:didAdd:error:): On success, sets isServicesBuilt = true and emits onStartGattServer. On error, sets isServicesBuilt = false.
Central Subscription Handling
func peripheralManager(_ peripheral: CBPeripheralManager,
central: CBCentral,
didSubscribeTo characteristic: CBCharacteristic) {
emitter?.sendEvent("onDeviceConnected", ["value": central.identifier.uuidString])
if !connectedCentrals.contains(central) {
connectedCentrals.append(central)
}
if characteristic.uuid == uuidCharForIndicate {
subscribedCentrals.append(central)
}
}
On iOS, connectedCentrals and subscribedCentrals are separate arrays. A central is added to connectedCentrals when it subscribes to any characteristic, and to subscribedCentrals only when subscribing specifically to the indicate characteristic.
didUnsubscribeFrom mirrors this: removes from both arrays and emits onDeviceDisconnected.
Read Requests
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
switch request.characteristic.uuid {
case uuidCharForRead:
request.value = advertisedName.data(using: .utf8)
peripheralManager.respond(to: request, withResult: .success)
default:
peripheralManager.respond(to: request, withResult: .attributeNotFound)
}
}
Write Requests
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
requests.forEach { (request) in
switch request.characteristic.uuid {
case uuidCharForWrite:
let data = request.value ?? Data()
let textValue = String(data: data, encoding: .utf8) ?? ""
if textValue.starts(with: "c:") {
if !connectedCentrals.contains(request.central) {
connectedCentrals.append(request.central)
emitter?.sendEvent("onDeviceConnected", ...)
}
}
emitter?.sendEvent("onPacketFromClient", ["value": [
"data": textValue,
"from": request.central.identifier.uuidString
]])
peripheral.respond(to: request, withResult: .success)
default:
peripheral.respond(to: request, withResult: .attributeNotFound)
}
}
}
The "c:" prefix check is iOS-specific. On iOS, a central might send a write before subscribing to the indicate characteristic. The server proactively adds the central to connectedCentrals when it sees the connection packet, ensuring the central is tracked even without a subscription event first.
Broadcasting and Targeted Send
func broadcastPacket(_ valueString: String) {
guard let charForIndicate = charForIndicate else { return }
let data = valueString.data(using: .utf8) ?? Data()
let result = peripheralManager.updateValue(data, for: charForIndicate, onSubscribedCentrals: nil)
}
func sendPacketToClient(_ valueString: String, deviceId: String) {
guard let charForIndicate = charForIndicate else { return }
let data = valueString.data(using: .utf8) ?? Data()
let centrals = connectedCentrals.filter { central in
central.identifier.uuidString == deviceId
}
let result = peripheralManager.updateValue(data, for: charForIndicate, onSubscribedCentrals: centrals)
}
onSubscribedCentrals: nil means broadcast to all subscribed centrals. onSubscribedCentrals: centrals sends only to the filtered set.
The updateValue method returns false if the internal transmit queue is full (indicates the app should retry later). The current implementation logs the result but does not implement retry logic.
Key iOS vs Android Differences
| Aspect | Android | iOS |
|---|---|---|
| Peripheral class | BluetoothGattServer via BluetoothManager.openGattServer() | CBPeripheralManager |
| Service addition | gattServer.addService(service) (async callback) | peripheralManager.add(service) (async callback) |
| Advertising data | Service UUID in ad data, device name in scan response | Device name + service UUID both in ad dictionary |
| Indication delivery | notifyCharacteristicChanged(device, char, false, value) | updateValue(data, for: char, onSubscribedCentrals: centrals) |
| Device identification | MAC address (device.address) | UUID string (central.identifier.uuidString) |
| Connection tracking | mutableSetOf<BluetoothDevice> for connected + subscribed | [CBCentral] arrays for connected + subscribed |
| Write handling | Single onCharacteristicWriteRequest callback | didReceiveWrite with array of requests (batched) |
| Read handling | Returns empty or logged for CHAR_FOR_READ | Returns advertisedName dynamically |
| Android version handling | API 33+ uses new notifyCharacteristicChanged signature | No version branching needed |
| CCC descriptor | Manually created and added to indicate characteristic | CoreBluetooth manages CCC automatically (no manual descriptor needed) |
The absence of a manually-created CCC descriptor on iOS is significant: CoreBluetooth automatically handles the Client Characteristic Configuration Descriptor for .indicate and .notify characteristics. On Android, the descriptor must be explicitly created and attached.
TypeScript Bridge Layer
BleManagerModule.ts
File: modules/ble-manager/src/BleManagerModule.ts
import {requireNativeModule} from 'expo-modules-core';
export default requireNativeModule('BleManager');
This is the Expo Modules bridge. requireNativeModule loads the native module object from JSI (new architecture) or falls back to NativeModulesProxy (bridge mode / remote debugging).
BlePeripheralManager.ts
File: modules/ble-manager/src/BlePeripheralManager.ts
export default class BlePeripheralManager {}
An empty class. The peripheral manager is accessed through BleManagerModule, not through this class directly. This file exists as a placeholder or forward declaration.
Events.types.ts
File: modules/ble-manager/src/Events.types.ts
export interface EventBase {}
export interface EventWithPayload<T> extends EventBase {
value: T;
}
export interface AdvertisingEvent {
advertisingName: string;
success: boolean;
errorCode?: number;
errorMessage?: string;
}
export interface PacketFromClientEvent {
data: string;
from: string;
}
export type EventNames =
| "onStartGattServer"
| "onStopGattServer"
| "onStartAdvertising"
| "onStopAdvertising"
| "onDeviceConnected"
| "onDeviceDisconnected"
| "onNotificationSent"
| "onPacketFromClient"
| "onCamdomLogoPressed";
onCamdomLogoPressed is a JavaScript-only event (not emitted from native).
Utils.ts
File: modules/ble-manager/src/Utils.ts
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function log(msg: string, ...args: any[]) {
console.log(msg, ...args);
}
export function logEvent(name: string, msg?: string, ...args: any[]) {
console.log(msg ?? name, ...args);
}
sleep is used extensively in the TypeScript layer for timing: 300ms after connection before sending the connection packet, 3500ms after sync before enabling RSSI, etc.
index.ts
File: modules/ble-manager/index.ts
import BleManager from "./src/BleManager";
export default BleManager;
export { BleManager };
Note: exports BleManager (the state machine class), NOT BleManagerModule (the native bridge).
BleManager.ts — The State Machine
File: modules/ble-manager/src/BleManager.ts
This is the largest and most complex file. It is a singleton class that wraps both the native BleManagerModule (server role) and react-native-ble-plx (client role) into a unified state machine.
Initialization
const manager = new BleManager(
BleManagerModule ?? NativeModulesProxy.BleManager,
);
export default manager;
A single instance is created at module load time.
The constructor initializes:
- MMKV storage (loads alarm and RSSI sensitivity settings)
EventEmitterwrapping the native moduleBlePlxManagerwith state restoration (restoreStateIdentifier: "camdom-app")- Sound effects (5 audio files: open, pair, unpair, alarm, ping)
- Audio mode configuration (
staysActiveInBackground: true,playsInSilentModeIOS: true)
State Variables
private isServer?: boolean = undefined;
private isScanning = false;
private isConnecting = false;
private advertisingLocalValue: number | null = null;
private connectedDevice?: Device; // BLE client connection
private connectedClients: string[] = []; // server: connected central IDs
private askedDisconnectionDevices: string[] = [];
private allowedClientsToDisconnect: string[] = [];
private askedToStopAlarmClients: string[] = [];
private rssiUpdatesFromClients = new Map<string, number[]>();
private connectionPacketSent?: string;
private sessionId?: string;
AppState Listener (Background Alarm)
When the app moves to background/inactive while in synced state:
- If server: fires alarm locally (Android only, plays sound), broadcasts
"a:f|1|u"(alarm fire, level 1, unauthorized disconnect) - If client: sends
"a:u|i"(alarm unauthorized, intent)
Android also listens for the blur event (RN-specific) for additional background detection.
BLE State Change Handler
Listens to blePlxManager.onStateChange. On any state other than PoweredOn, resets all connection state, stops scanning and advertising.
Cross-Platform Abstraction
Advertising Value Ranges
The role election uses numeric device names with platform-specific ranges:
this.advertisingLocalValue =
Platform.OS === "ios"
? this.randomIntFromInterval(1, 124)
: this.randomIntFromInterval(126, 249);
| Platform | Range | Purpose |
|---|---|---|
| iOS | 1-124 | Lower values (becomes client if seeing higher) |
| Android | 126-249 | Higher values (becomes server if seeing lower) |
When falling back during scan (no prior server found):
this.module.bindServer(Platform.OS === "ios" ? "125" : 251);
This ensures iOS servers get value 125 (highest in iOS range, but lower than any Android value) and Android servers get 251 (highest possible).
Scan Timing
setTimeout(async () => {
await this.blePlxManager.startDeviceScan(...)
}, Platform.OS === "ios" ? 400 : 0);
iOS has a 400ms delay before starting scanning, likely to allow the Bluetooth stack to initialize fully after advertising starts.
MTU Negotiation
this.connectedDevice = await this.connectedDevice.requestMTU(53);
The client requests an MTU of 53 bytes. This value is chosen to fit within the BLE 4.2 extended packet size while being conservative enough for most devices. The actual MTU may be negotiated down by the server.
Client Connection Packet
this.connectionPacketSent = `c:${Platform.OS === "ios" ? 0 : 1}:${this.advertisingLocalValue}`;
Format: c:<platformFlag>:<localValue> where platformFlag is 0 for iOS, 1 for Android. This allows the server to know the client's platform.
iOS Scan Retry Logic
iOS has a special scan retry mechanism:
if (Platform.OS === "ios") {
if (remoteAdvertisedValue <= 125) {
if (this.scanCountRetriesIos <= 10) {
this.scanCountRetriesIos++;
this.lastSeenHigherNumber = remoteAdvertisedValue > this.lastSeenHigherNumber
? remoteAdvertisedValue : this.lastSeenHigherNumber;
return; // skip this device
}
}
}
iOS scans up to 10 times, tracking the highest number seen, before proceeding. This accounts for iOS's slower scan result delivery.
Packet Encoding and Decoding
Base64 Encoding
Packets are Base64-encoded before transmission over BLE. On the client side:
await this.connectedDevice.writeCharacteristicWithResponseForService(
SERVICE_UUID,
CHAR_FOR_WRITE_UUID,
btoa(packet), // JavaScript btoa() for Base64 encoding
);
On the client receive side (monitoring the indicate characteristic):
const packet = atob(characteristic?.value ?? "");
this.handlePacketFromServer(packet);
On the native server side, packets arrive already Base64-decoded (the native layer handles the encoding/decoding transparently through the BLE stack's byte handling). The server receives raw UTF-8 strings via value?.toString(Charsets.UTF_8) (Android) or String(data: data, encoding: .utf8) (iOS).
Server-Side Broadcast
The server sends raw strings (not Base64-encoded) through the native module:
this.module.broadcastPacket(`sound:1|${SoundsToPacketMapper(key)}`);
The native module converts to ByteArray (Android) or Data (iOS) using UTF-8 encoding.
Packet Structure
All packets follow the format: <type>:<content>
The content may contain pipe-delimited fields: <field1>|<field2>|<field3>
See Full Packet Protocol Reference below.
Role Election Algorithm
The role election determines which device becomes the GATT server and which becomes the client. It runs during scanning.
Algorithm
- Both devices start advertising simultaneously with random numeric names
- Both devices scan for other devices advertising the CAMDOM service UUID
- When a device sees another:
- Parse the remote device's advertised name as an integer (
remoteAdvertisedValue) - Compare with local value (
localAdvertisedValue) - If
remoteAdvertisedValue > localAdvertisedValue: The remote device has higher priority → connect to it as a client - If
remoteAdvertisedValue <= localAdvertisedValue: This device has higher priority → stay as server
- Parse the remote device's advertised name as an integer (
Platform Asymmetry
iOS devices use range 1-124, Android devices use range 126-249. This means:
- An Android device will always become the server when paired with an iOS device (higher number wins)
- Two devices of the same platform use random values within their range
Fallback (No Server Found)
If scanning completes without finding a server (timeout at 7500ms), the scanning device becomes the server:
this.module.bindServer(Platform.OS === "ios" ? "125" : 251);
this.advertisingLocalValue = Platform.OS === "ios" ? 125 : 251;
this.isServer = true;
Migration
When a server detects a new device with a higher number while already serving clients:
if (this.isServer && remoteAdvertisedValue >= 126) {
this.module.broadcastPacket(`m:d|${remoteAdvertisedValue.toString()}`);
this.isMigratingConnection = true;
}
The server sends a migration packet (m:d|<newServerValue>) to all clients, then disconnects and reconnects as a client to the new server.
BLE Plx Configuration
this.blePlxManager = new BlePlxManager({
restoreStateIdentifier: "camdom-app",
restoreStateFunction: (restoredState) => {
if (restoredState) {
this.connectedClients = restoredState.connectedPeripherals.map(p => p.id);
}
},
});
The restoreStateIdentifier enables BLE state restoration on iOS, allowing the app to recover connections after being suspended.
Background Execution
Audio Mode Configuration
await Audio.setAudioModeAsync({
staysActiveInBackground: true,
playsInSilentModeIOS: true,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
shouldDuckAndroid: false,
playThroughEarpieceAndroid: false,
});
staysActiveInBackground: true keeps the audio session active when the app is backgrounded, which helps maintain the BLE connection on iOS (the audio session acts as a background mode anchor).
AppState Listeners
The app monitors AppState changes to detect backgrounding:
AppState.addEventListener("change", (state) => {
if (state === "background" || state === "inactive") {
// Fire alarm on background
}
});
Android additionally monitors the blur event (React Native's event for when the activity loses focus).
Android Forensic Considerations
The MainActivity.kt and MainApplication.kt do not implement any custom foreground service or background BLE handling. The BLE connection is maintained through React Native's standard lifecycle management and the Expo Modules framework. The ApplicationLifecycleDispatcher.onApplicationCreate(this) in MainApplication handles Expo's lifecycle hooks.
iOS Background BLE
iOS uses CBPeripheralManager which operates independently of the app lifecycle. The CBPeripheralManager is initialized on queue: nil (main queue) and continues running as long as the BLE subsystem is powered on. The staysActiveInBackground audio mode helps keep the app process alive.
Error Handling
BLE Unavailable
When BLE state changes to anything other than PoweredOn:
case "Unauthorized":
case "Unsupported":
case "Resetting":
case "Unknown":
case "PoweredOff": {
this.sharedValues.appState.value = "disconnected";
// Reset all connection state
this.allowedClientsToDisconnect = [];
this.askedDisconnectionDevices = [];
// ... full state reset
this.stopScanning();
this.stopAdvertising(true);
break;
}
Advertising Failure
On iOS, peripheralManagerDidStartAdvertising checks for errors:
if let error = error {
emitter?.sendEvent("onStartAdvertising", ["value": [
"advertisingName": advertisedName,
"success": false,
"errorCode": 0,
"errorMessage": error.localizedDescription
]])
emitter?.sendEvent("onStopAdvertising")
}
On Android, onStartFailure maps error codes:
ADVERTISE_FAILED_DATA_TOO_LARGE— name too long for ad packetADVERTISE_FAILED_TOO_MANY_ADVERTISERS— hardware limit reachedADVERTISE_FAILED_ALREADY_STARTED— advertising already activeADVERTISE_FAILED_INTERNAL_ERROR— Bluetooth stack errorADVERTISE_FAILED_FEATURE_UNSUPPORTED— BLE advertising not supported
The TypeScript layer handles advertising failure by showing a Snackbar error and checking iOS Bluetooth permissions.
Connection Timeout
The scanning timeout is 7500ms:
this.scanningTimeout = setTimeout(() => {
if (this.isConnecting || this.connectedDevice || /* already connected */) {
// Wait 5 more seconds, then show error
setTimeout(() => {
if (this.isConnecting) {
this.sharedValues.hasConnectionError.value = true;
this.sharedValues.appState.value = "disconnected";
}
}, 5000);
return;
}
// Full reset and retry scan
this.disconnectFromServer().then(() => {
this.stopScanning().then(() => {
this.stopAdvertising();
this.startScanning();
});
});
}, 7500);
If the device is already connecting when the timeout fires, it waits an additional 5 seconds before giving up.
Write Error Handling
On the client side, write errors trigger disconnection:
try {
await this.connectedDevice.writeCharacteristicWithResponseForService(...)
} catch (e) {
await this.disconnectFromServer();
this.sharedValues.appState.value = "disconnected";
this.sharedValues.hasConnectionError.value = !this.hasReceivedDisconnectFromServer;
}
On the native Android side, onCharacteristicWrite errors emit onWriteError with the status code.
Permission Denied
The TypeScript layer catches permission errors on bindServer:
try {
this.module.bindServer(this.advertisingLocalValue.toString());
} catch (e) {
await requestPermissions((granted) => {
if (granted) return this.startAdvertising();
});
}
On iOS, when advertising fails, the layer checks PERMISSIONS.IOS.BLUETOOTH permission status and requests it if denied but requestable.
MTU Negotiation
this.connectedDevice = await this.connectedDevice.requestMTU(53);
If MTU negotiation fails, the error propagates and the connection is reset:
try {
this.connectedDevice = await this.connectedDevice.requestMTU(53);
this.connectedDevice = await this.connectedDevice.discoverAllServicesAndCharacteristics();
// ... subscribe to indications
} catch (e) {
this.sharedValues.appState.value = "disconnected";
this.sharedValues.hasConnectionError.value = true;
this.isConnecting = false;
return;
}
Application Entry Points
MainActivity.kt
File: android/app/src/main/java/com/billyboy/condomapp/MainActivity.kt
Standard Expo/React Native activity:
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
SplashScreenManager.registerOnActivity(this)
super.onCreate(null)
}
override fun getMainComponentName(): String = "main"
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {}
)
}
}
Key details:
super.onCreate(null)— passesnullsavedInstanceState, standard for React Native (prevents state restoration issues)mainComponentNamereturns"main"— matches the root component registration in JavaScriptDefaultReactActivityDelegatewithfabricEnabledenables the new React Native architecture (Fabric renderer + TurboModules) whenBuildConfig.IS_NEW_ARCHITECTURE_ENABLEDis trueinvokeDefaultOnBackPressedis overridden to callmoveTaskToBack(false)on Android R and below, aligning back button behavior with Android S guidelines
MainApplication.kt
File: android/app/src/main/java/com/billyboy/condomapp/MainApplication.kt
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsMainModulePath = ".expo/.virtual-metro-entry",
useDevSupport = BuildConfig.DEBUG
)
}
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = ReleaseLevel.STABLE
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
}
Key details:
PackageList(this).packages— automatically discovers and registers all React Native packages (including the BleManager module) via Expo's autolinkingjsMainModulePath = ".expo/.virtual-metro-entry"— Expo's virtual entry point for Metro bundlerReleaseLevel.STABLE— opts into the stable new architecture API surfaceApplicationLifecycleDispatcher— forwards application lifecycle events to Expo modules- No custom
ReactNativeHost— usesreactHost(new architecture pattern) instead
Full Packet Protocol Reference
Packet Type Summary
| Prefix | Direction | Meaning |
|---|---|---|
c: | Client → Server | Connection handshake |
s: | Bidirectional | Session assignment |
d: | Bidirectional | Disconnection negotiation |
m: | Server → Client | Migration |
a: | Bidirectional | Alarm control |
sound: | Server → Client | Sound synchronization |
n: | Bidirectional | Notification / RSSI |
ping | Client → Server | Keep-alive ping |
pong | Server → Client | Keep-alive response |
discq: | Client → Server | Disconnect query |
disca: | Server → Client | Disconnect answer |
Connection Handshake (c:)
c:<platform>:<localValue>
platform: 0 = iOS, 1 = AndroidlocalValue: The advertising numeric identifier
The server echoes back the exact packet. The client verifies the echo matches connectionPacketSent to confirm the connection is established.
Session Assignment (s:)
Client → Server: s:r|<shouldFire>
Server → Client: s:a|<sessionId>
shouldFire: 0 or 1, whether client has "fire alarm on disconnect" enabledsessionId: 8-character UUID substring
Disconnection (d:)
Client → Server: d:r|s (start disconnect request)
Client → Server: d:r|f (finish disconnect request)
Client → Server: d:r|1 (confirm disconnect allowed)
Client → Server: d:r|ff (force finish / edge case)
Server → Client: d:a|a (accept disconnect - all clients)
Server → Client: d:a|a1 (accept disconnect - broadcast)
Server → Client: d:a|r (reject disconnect)
Server → Client: d:a|a1 (migration disconnect)
Alarm (a:)
Server → Client: a:f|1|a (fire alarm, level 1, all)
Server → Client: a:f|1|u (fire alarm, level 1, unauthorized)
Server → Client: a:f|0|a (stop alarm, level 0, all)
Client → Server: a:r|s (request stop alarm - start)
Client → Server: a:r|f (request stop alarm - finish)
Client → Server: a:r|ff (force finish - edge case)
Client → Server: a:u|i (unauthorized disconnect intent)
Client → Server: a:u|rssi|<rssi>|<sense> (RSSI update)
Migration (m:)
Server → Client: m:d|<newServerValue> (migrate to new server)
Client → Server: m:d|0 (migration refused)
Client → Server: m:d|<value> (migration accepted)
Sound Synchronization (sound:)
Server → Client: sound:1|<soundCode> (play sound)
Server → Client: sound:0|<soundCode> (stop sound)
Sound codes: 0 = open, 1 = pair, 2 = unpair, 3 = alarm, 4 = ping
Keep-Alive (ping: / pong:)
Client → Server: ping:<sessionId>
Server → Client: pong:<sessionId>
Disconnect Query (discq: / disca:)
Client → Server: discq:<sessionId>
Server → Client: disca:1
Used for state synchronization during disconnect sequences.
Android BLE Permission Model
The @SuppressLint("MissingPermission") annotations throughout the Kotlin code suppress the lint warning for BLE permission checks. At runtime, permissions are handled by:
react-native-permissionslibrary (checked in TypeScript)- Expo's permission request flow (
requestPermissionsinBleManager.ts) - Android manifest declarations (handled by Expo prebuild)
Required Android permissions:
BLUETOOTH_SCAN(API 31+)BLUETOOTH_CONNECT(API 31+)BLUETOOTH_ADVERTISE(API 31+)ACCESS_FINE_LOCATION(API 30 and below)BLUETOOTH(legacy)ACCESS_COARSE_LOCATION(legacy)
RSSI Monitoring
The codebase contains RSSI monitoring infrastructure that is currently commented out but architecturally present:
Client-side (configureClientRSSITimeout): Reads RSSI from the connected server every 15ms, sends a:u|rssi|<rssiValue>|<senseValue> packets.
Server-side (configureServerRSSITimeout): Collects RSSI readings from clients into rssiUpdatesFromClients map (sliding window of 5 readings per client), computes mean, and triggers distance alarm when threshold is exceeded.
handleRssiUpdateFromClient: Maintains a sliding window of 5 RSSI values per client. If the rolling average exceeds the sensitivity threshold (stored in MMKV as StorageKeys.rssiSense, default 60), fires a distance alarm.
The sensitivity system supports per-client overrides: if any client reports a lower sensitivity value, the server uses that as the threshold. This ensures the most sensitive user's setting is honored.
Sound Effects System
Five sound effects are preloaded at startup:
| Key | File | Packet Code |
|---|---|---|
open | BillyBoy_Camdom_02_Open.mp3 | 0 |
pair | BillyBoy_Camdom_02_Pair.mp3 | 1 |
unpair | BillyBoy_Camdom_02_Unpair.mp3 | 2 |
alarm | BillyBoy_Camdom_Alarm_2.mp3 | 3 |
ping | ping.mp3 | 4 |
When the server plays a sound, it broadcasts sound:1|<code> to all clients, which play the same sound locally. The alarm sound loops (setIsLoopingAsync(true)) until explicitly stopped.
The volume is forced to 1.0 before playing:
if ((await VolumeManager.getVolume()).volume < 1) {
await VolumeManager.showNativeVolumeUI({ enabled: true });
await VolumeManager.setVolume(1);
}
Session ID
Generated when the first client connects to a server:
if (this.connectedClients.length === 1) {
this.sessionId = uuid.v4().substring(0, 8);
}
An 8-character UUID substring used for:
- Session identification in the protocol
- State synchronization during disconnect queries
- Preventing stale disconnect commands from affecting new sessions
The session ID is cleared on disconnection and migration.
File Reference
| File | Path | Lines | Purpose |
|---|---|---|---|
BleManager.kt | modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BleManager.kt | 753 | Legacy Android GATT client + server |
BleManagerModule.kt | modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BleManagerModule.kt | 57 | Expo Module bridge (Android) |
BlePeripheralManager.kt | modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BlePeripheralManager.kt | 452 | Production Android GATT server |
BleManagerModule.swift | modules/ble-manager/ios/BleManagerModule.swift | 43 | Expo Module bridge (iOS) |
BlePeripheralManager.swift | modules/ble-manager/ios/BlePeripheralManager.swift | 273 | iOS CBPeripheralManager |
BleManager.ts | modules/ble-manager/src/BleManager.ts | 1878 | State machine, protocol, UI binding |
BleManagerModule.ts | modules/ble-manager/src/BleManagerModule.ts | 5 | Native module require |
BlePeripheralManager.ts | modules/ble-manager/src/BlePeripheralManager.ts | 1 | Placeholder |
Events.types.ts | modules/ble-manager/src/Events.types.ts | 28 | TypeScript event types |
Utils.ts | modules/ble-manager/src/Utils.ts | 12 | Utility functions |
index.ts | modules/ble-manager/index.ts | 4 | Module entry point |
MainActivity.kt | android/app/src/main/java/com/billyboy/condomapp/MainActivity.kt | 66 | Android activity |
MainApplication.kt | android/app/src/main/java/com/billyboy/condomapp/MainApplication.kt | 46 | Android application |