---
type: reference
title: "CAMDOM — Native Module Implementation (Kotlin + Swift)"
description: "Complete native BLE implementation: Android GATT Server, iOS CBPeripheralManager, Expo Modules bridge, packet encoding, background execution."
tags: [camdom, native-modules, kotlin, swift, ble, gatt, expo-modules]
timestamp: "2026-07-20"
---

# 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

1. [Architecture Overview](#architecture-overview)
2. [GATT Service Architecture](#gatt-service-architecture)
3. [Android Native Layer (Kotlin)](#android-native-layer-kotlin)
4. [iOS Native Layer (Swift)](#ios-native-layer-swift)
5. [TypeScript Bridge Layer](#typescript-bridge-layer)
6. [Cross-Platform Abstraction](#cross-platform-abstraction)
7. [Packet Encoding and Decoding](#packet-encoding-and-decoding)
8. [Role Election Algorithm](#role-election-algorithm)
9. [Background Execution](#background-execution)
10. [Error Handling](#error-handling)
11. [Application Entry Points](#application-entry-points)
12. [Full Packet Protocol Reference](#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

1. Client writes `ENABLE_INDICATION_VALUE` (`0x0100`) to CCC descriptor of `25AE1444`
2. Server's `onDescriptorWriteRequest` adds device to `subscribedDevices` set (Android) or `subscribedCentrals` array (iOS)
3. Server can now push data via `broadcastPacket` or `sendPacketToClient`
4. 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`.

```kotlin
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):

```kotlin
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 to `peripheralManager.updateAdvertisedName(name)`
- `unbindServer(stopServer: Boolean)` — Delegates to `peripheralManager.bleStopAdvertising(stopServer)`
- `broadcastPacket(packet: String)` — Converts to `ByteArray` and delegates to `peripheralManager.broadcastPacket(packet.toByteArray())`
- `sendPacketToClient(packet: String, to: String)` — Converts to `ByteArray` and delegates to `peripheralManager.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

```kotlin
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:

```kotlin
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:

```kotlin
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 to `subscribedDevices`, returns `GATT_SUCCESS`
- `DISABLE_NOTIFICATION_VALUE` → removes device from `subscribedDevices`, returns `GATT_SUCCESS`
- Anything else → returns `GATT_REQUEST_NOT_SUPPORTED`

#### Broadcast and Targeted Send

```kotlin
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

```kotlin
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:

```kotlin
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

```kotlin
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:

1. Parse the scanned device's name as an integer (`nameInt`)
2. If `localNumber` is 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)
3. If `localNumber` is 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

```kotlin
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`

```swift
public class BleManagerModule: Module {
    private let peripheralManager: BlePeripheralManager = BlePeripheralManager.shared.self
```

Uses the singleton pattern via `BlePeripheralManager.shared`. On `OnCreate`, binds the emitter:

```swift
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:

```swift
public static let shared: BlePeripheralManager = {
    if _sharedInstance == nil {
        _sharedInstance = BlePeripheralManager()
    }
    return _sharedInstance
}()
```

#### CoreBluetooth Initialization

```swift
private override init() {
    super.init()
    peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
```

`queue: nil` means callbacks are dispatched on the main queue.

#### Service Building

```swift
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

```swift
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

```swift
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

```swift
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

```swift
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

```swift
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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

```typescript
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)
- `EventEmitter` wrapping the native module
- `BlePlxManager` with state restoration (`restoreStateIdentifier: "camdom-app"`)
- Sound effects (5 audio files: open, pair, unpair, alarm, ping)
- Audio mode configuration (`staysActiveInBackground: true`, `playsInSilentModeIOS: true`)

#### State Variables

```typescript
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:

```typescript
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):
```typescript
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

```typescript
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

```typescript
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

```typescript
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:

```typescript
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:

```typescript
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):

```typescript
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:

```typescript
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](#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

1. Both devices start advertising simultaneously with random numeric names
2. Both devices scan for other devices advertising the CAMDOM service UUID
3. 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

### 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:

```typescript
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:

```typescript
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

```typescript
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

```typescript
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:

```typescript
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`:

```typescript
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:

```swift
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 packet
- `ADVERTISE_FAILED_TOO_MANY_ADVERTISERS` — hardware limit reached
- `ADVERTISE_FAILED_ALREADY_STARTED` — advertising already active
- `ADVERTISE_FAILED_INTERNAL_ERROR` — Bluetooth stack error
- `ADVERTISE_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:

```typescript
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:

```typescript
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`:

```typescript
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

```typescript
this.connectedDevice = await this.connectedDevice.requestMTU(53);
```

If MTU negotiation fails, the error propagates and the connection is reset:

```typescript
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:

```kotlin
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)` — passes `null` savedInstanceState, standard for React Native (prevents state restoration issues)
- `mainComponentName` returns `"main"` — matches the root component registration in JavaScript
- `DefaultReactActivityDelegate` with `fabricEnabled` enables the new React Native architecture (Fabric renderer + TurboModules) when `BuildConfig.IS_NEW_ARCHITECTURE_ENABLED` is true
- `invokeDefaultOnBackPressed` is overridden to call `moveTaskToBack(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`

```kotlin
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 autolinking
- `jsMainModulePath = ".expo/.virtual-metro-entry"` — Expo's virtual entry point for Metro bundler
- `ReleaseLevel.STABLE` — opts into the stable new architecture API surface
- `ApplicationLifecycleDispatcher` — forwards application lifecycle events to Expo modules
- No custom `ReactNativeHost` — uses `reactHost` (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 = Android
- `localValue`: 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" enabled
- `sessionId`: 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:

1. `react-native-permissions` library (checked in TypeScript)
2. Expo's permission request flow (`requestPermissions` in `BleManager.ts`)
3. 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:
```typescript
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:

```typescript
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 |
