---
type: reference
title: "CAMDOM -- System Design: Delivered vs Conceptual"
description: "Gap analysis between designed and shipped: RSSI proximity (disabled), shared state dilemmas, GATT device handling, disconnect protocol, alarm trigger paths, feature completeness."
tags: [camdom, system-design, technical-debt, gap-analysis, post-mortem]
timestamp: "2026-07-20"
---

# CAMDOM -- System Design: Delivered vs Conceptual

> An honest post-mortem of what was designed, what was built, what shipped, and what was quietly buried in commented-out code.

---

## 1. Conceptual Architecture vs Delivered Product

### What Was Designed (Full Vision)

The original CAMDOM vision included a proximity-based alarm system that would automatically detect when devices moved apart and fire the alarm without requiring any manual action. The core idea: use BLE RSSI (Received Signal Strength Indicator) as a proxy for physical distance, and when the signal weakens past a configurable threshold, trigger the alarm.

**The designed proximity system had these components:**

- **Client-side RSSI reader** (`configureClientRSSITimeout`): A 15ms tick loop that called `connectedDevice.readRSSI()` and sent the value to the server as a packet: `a:u|rssi|{rssi}|{sense}`. This ran at approximately 45fps.
- **Server-side RSSI aggregator** (`configureServerRSSITimeout`): A 30fps loop that collected RSSI values from all connected clients, computed a rolling window average (last 5 readings per client), and compared the mean against a threshold.
- **The `n:rssi|1` notification packet**: A broadcast to all clients announcing that proximity sensors were active, accompanied by a "Proximity Sensors Enabled" snackbar and a ping sound.
- **The `hasAnyoneHigherSensibility` negotiation protocol**: When a client sent its RSSI packet, it included its local sensitivity setting. The server compared all clients' sensitivity values and used the **highest** sensitivity (lowest numeric value = most sensitive) as the threshold. This ensured that if one partner wanted tighter proximity detection, their preference would win.
- **The `rssiSense` storage key**: A configurable sensitivity value (default 60) stored in MMKV, accessible via the Options screen.
- **Automatic disconnect on distance**: If the server-side average RSSI exceeded the threshold for 1000ms, the server would broadcast `d:a|a1` (disconnect all), clear the advertising value, and set state to `disconnected`.
- **Automatic alarm on distance**: If the mean RSSI exceeded the threshold, the server would broadcast `a:f|1` (fire alarm) and set `distanceAlarmFired = true`.

**Reconstructed data flow:**

```
Client (phone A)                    Server (phone B - the GATT peripheral)
     |                                        |
     |  readRSSI() every 15ms                 |
     |  ---a:u|rssi|-72|60-->                 |
     |                                        |  store in rssiUpdatesFromClients Map
     |                                        |  maintain rolling window [5 values]
     |                                        |
     |                                        |  every 15ms: compute mean of all clients
     |                                        |  if mean >= threshold (hasAnyoneHigherSensibility ?? rssiSense):
     |                                        |    broadcast a:f|1 --> ALARM
     |                                        |  else after 1000ms:
     |                                        |    broadcast d:a|a1 --> DISCONNECT
```

### What Was Actually Shipped

The proximity system is **completely commented out** in the production codebase. The commit `2f701d2` ("Disable proximity sensors", Sep 3, 2024) systematically commented out every piece of the proximity pipeline while leaving the infrastructure (timers, storage keys, handler stubs) intact.

**What actually shipped as alarm triggers:**

1. **App backgrounding while synced** (lines 257-276 in BleManager.ts): When the OS moves the app to background, if state is `synced`, the server plays alarm and broadcasts `a:f|1|u`. Client reports back `a:u|i`.
2. **Android blur event while synced** (lines 279-300): Android-specific duplicate of the backgrounding logic.
3. **Disconnect attempt by any device** (lines 1120-1130): When `onDisconnectPressFinish` fires and `hasAnyoneRequestedToFireWhenDisconnect` or the `fireAlarmOnDisconnect` storage flag is true, the alarm broadcasts `a:f|1|a`.
4. **Manual alarm via UI button**: The `onStopAlarmPressStart` flow, which is a press-and-hold mechanism requiring all devices to agree before silencing.
5. **Server-side disconnect with fireOnDisconnect=true**: When a client sends `s:r|1` (meaning it has fire-on-disconnect enabled), the server sets `hasAnyoneRequestedToFireWhenDisconnect = true`.

**The sensitivity slider in Options.tsx exists but does nothing.** The original Options page (commit `749d1c8`) had sign-out and delete-account buttons plus a privacy policy link. In the current codebase, the sign-out and account-deletion buttons are commented out (lines 117-144 of Options.tsx), and there is no sensitivity slider at all in the current UI. The `rssiSense` storage key still exists in `StorageKeys.ts` with its hashed name, and the constructor still initializes it to 60, but nothing reads or writes it from any user-facing interface.

### Why It Was Disabled

The git history tells the story. The feature was introduced in commit `749d1c8` (v2.33.0, Aug 20, 2024) titled "Disconnection by distance" and disabled 14 days later in commit `2f701d2` (Sep 3, 2024). The speed of the disable suggests the problems were encountered immediately in testing.

**RSSI noise and device-specific behavior:**

BLE RSSI values are notoriously unreliable as distance proxies. The raw signal fluctuates wildly based on:
- Device orientation (phone held differently in hand vs pocket)
- Body proximity (human tissue absorbs 2.4GHz signals)
- Environmental reflections (multipath fading)
- Device hardware differences (antenna quality varies dramatically between a $100 Android and a $1000 iPhone)
- Advertising power settings (the code uses `ADVERTISE_TX_POWER_HIGH` on Android, which maximizes range but also maximizes noise)

A rolling window of 5 samples at 30fps means each sample covers ~16.6ms. RSSI readings at this timescale can vary by 10-15 dBm between consecutive reads, making the "mean" computation meaningless for distance estimation without much heavier filtering.

**False positives from signal fluctuation:**

The design had the server firing an alarm when the mean RSSI exceeded the threshold. In practice, a momentary signal dip (someone turned their body, or a metal object briefly interfered) would trigger a false alarm. With the sensitivity default set to 60, and typical BLE RSSI values ranging from -30 (very close) to -90 (very far), the threshold mapping was unclear and likely produced false positives in normal usage.

**The sensitivity negotiation was broken at a protocol level:**

The `hasAnyoneHigherSensibility` comparison logic (lines 1720-1732) has a subtle bug. When `localSensibility < userSense` (server is more sensitive than client) AND `localSensibility < this.hasAnyoneHigherSensibility`, it sets `hasAnyoneHigherSensibility = undefined`. This means if the server is the most sensitive device, the comparison falls back to `sense` (the raw storage value), but `undefined` is not the same as "use local." The negotiation protocol was designed for a world where every device agrees on what "sensitive" means, but RSSI is device-relative, making the whole comparison meaningless.

**Battery and performance concerns:**

The client-side RSSI reader ran at 15ms intervals (approximately 66fps). `readRSSI()` is a synchronous BLE operation that blocks the radio. Running it this frequently on both devices simultaneously would:
- Double the BLE radio traffic (each device polling the other)
- Prevent the radio from handling other BLE operations during the read
- Drain battery significantly during extended sessions
- Potentially interfere with the GATT indication channel (the indication packets carrying the actual protocol data)

**Multi-device averaging complexity:**

The server was designed to aggregate RSSI from all connected clients simultaneously. But BLE is a shared medium -- when the server is reading RSSI from Client A, it cannot simultaneously receive packets from Client B. The 30fps tick rate for server-side averaging meant the server was spending most of its time in RSSI reading loops rather than handling the actual protocol.

---

## 2. The Shared State Dilemma

### The Two State Systems

CAMDOM uses two parallel state management systems because of a fundamental architectural tension in React Native with Reanimated.

**React state** (useState hooks in `app/index.tsx`):
- `appCurrentState` -- mirrors `appState` SharedValue for use in React effects
- `shouldStartScan` -- triggers the scan lifecycle from React effects

**Reanimated state** (SharedValues):
- `appState` -- the canonical connection state (`disconnected`, `scanning`, `connecting`, `connected`, `synced`, `alarm`, `migrating`)
- `ballRadius` -- animation target for the ball's size
- `ballPosition` -- where the ball renders (`center` only now, was `left`/`right`/`center`)
- `isPeripheralServer` -- whether this device is the GATT server
- `hasConnectionError` -- controls error UI visibility
- `alarmButtonPosition` -- position of the alarm button (currently always `center`)

### The Bridge Problem

Reanimated SharedValues live on the UI thread. React state lives on the JS thread. BLE callbacks fire on the native thread. This creates a three-thread coordination problem.

**The `useAnimatedReaction` bridge (lines 421-427):**

```typescript
useAnimatedReaction(
  () => appState.value,
  (prep, previous) => {
    runOnJS(setAppCurrentState)(prep);
  },
  [appState.value],
);
```

This watches the SharedValue `appState` and, whenever it changes on the UI thread, posts a message to the JS thread to update `appCurrentState`. This is necessary because React effects (like the one at line 429 that manages the UI state transitions) can only run on the JS thread.

**The reverse bridge (lines 388-399):**

```typescript
useAnimatedReaction(
  () => showRedBall.value,
  (prep, previous) => {
    if (!previous && prep) {
      runOnJS(setShouldStartScan)(false);
    }
    if (previous && !prep) {
      runOnJS(setShouldStartScan)(true);
    }
  },
  [showRedBall.value],
);
```

The gesture handler sets `showRedBall` on the UI thread. The bridge translates this to `shouldStartScan` on the JS thread, which triggers the scan lifecycle effect.

### Race Conditions

**Race condition: Scan starts while already connecting.**

The scan callback (line 569) checks `if (this.isConnecting) return;` and `if (this.connectedDevice) return;`. But these checks happen on the native BLE thread, while `isConnecting` is set on the JS thread (line 638). There is a window where the scan callback fires, sees `isConnecting = false`, and starts a connection attempt, while a previous connection is still in progress. The `try/catch` at line 650 catches this, but the error path (line 655-673) resets state to disconnected, potentially interrupting the already-in-progress connection.

**Race condition: Alarm fires during disconnect negotiation.**

When the server is in the disconnect flow (`onDisconnectPressStart` polls `askedDisconnectionDevices` every 500ms), and simultaneously receives an alarm trigger (e.g., the client backgrounded), both paths modify `appState` and both paths call `broadcastPacket`. The server could broadcast `d:a|a` (disconnect accepted) and `a:f|1|a` (alarm) in the wrong order. The code partially handles this: `onDisconnectPressFinish` (line 1121-1130) checks `hasAnyoneRequestedToFireWhenDisconnect` and fires alarm instead of disconnect. But if the alarm fires *after* disconnect has already been accepted, the state machine can end up in an inconsistent state.

**Race condition: Server migrates while client sends packets.**

When the server sends `m:d|{newServerValue}` (migration packet), the client receives it and enters `migrating` state (line 1404). During this window, the client might still have pending packets queued for the old server. The `disconnectFromServer(false)` call at line 1409 cancels the connection, but any in-flight write operation on the old connection could throw. The `sendPacketToServer` method (line 817-866) checks `isStillConnected` before writing, but there is a TOCTOU (time-of-check-time-of-use) gap between the check and the actual write.

**The `isAmIClientAllowedToDisconnectEdgeCase` flag:**

This flag (introduced around commit `458183c`, "allow disconnection on special case when server closes the app") solves a specific problem: when the server backgrounded and triggered alarm state, the client received `a:f|1|u` (fire alarm, type user). The client then set `isAmIClientAllowedToDisconnectEdgeCase = true` and sent `discq:{sessionId}` to confirm awareness. Later, when the client tries to stop the alarm, it needs to send `a:r|ff` (forced disconnect finish) instead of the normal `a:r|f` (normal alarm stop finish), because the connection is already broken from the server side. Without this flag, the client would try to negotiate a disconnect with a server that has already gone to alarm state.

The naming of this flag (`isAmIClientAllowedToDisconnectEdgeCase`) is itself a symptom of the complexity. It encodes: "Am I the client? Is this the edge case where the server triggered alarm first and I need to handle disconnect differently?"

**The `retries` SharedValue as circuit breaker (line 307-324):**

```typescript
const retries = useSharedValue(7);

const retryConnect = async () => {
  await sleep(500);
  if (retries.value <= 5 && hasConnectionError.value) {
    retries.value += 1;
    // ... show retry snackbar, restart scanning
  }
};
```

`retries` starts at 7, decrements to 0 when scan starts, and allows up to 5 retries. This prevents infinite scan-reconnect loops when the server is unreachable. The use of a SharedValue (instead of a regular variable) suggests this was originally intended to be read from the UI thread for animation purposes, but the current code only reads it from JS via `runOnJS`.

---

## 3. GATT/BLE Device-Specific Issues

### Android-Specific

**API-level branching in permission requests (`requestPermissions.ts`):**

The code branches on API level 31 (Android 12):
- API < 31: Requests `ACCESS_FINE_LOCATION` (required for BLE scanning on older Android)
- API >= 31: Requests the three new Android 12 BLE permissions: `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE`

This is correct but leaves a gap: API 30 devices need `ACCESS_FINE_LOCATION` but the code only requests it, not `ACCESS_COARSE_LOCATION`. Some Android 10-11 devices require both.

**The `blur` event listener (lines 279-300):**

Android fires a `blur` event when the app loses focus (distinct from the `background` event). This is duplicated from the background handler because Android can blur the app without fully backgrounding it (e.g., when a dialog overlays the app). The duplicate code is a maintenance risk -- any change to the alarm logic must be applied in both places.

**GATT server callback differences in BleManager.kt:**

The Android native code (BleManager.kt) uses a classic `BluetoothGattServer` implementation with explicit characteristic subscription management via CCCD (Client Characteristic Configuration Descriptor). The `gattServerCallback` (line 550) handles `onDescriptorWriteRequest` and `onDescriptorWrite` manually, maintaining a `subscribedDevices` set. This is more explicit than the iOS implementation, which relies on CoreBluetooth's built-in subscription tracking.

Notable Android-specific issue: the `gattCallback.onConnectionStateChange` (line 318) has a TODO comment: "timeout timer: if this callback not called - disconnect(), wait 120ms, close()". This timeout was never implemented. On some Android devices, `onConnectionStateChange` is never called (the BLE stack hangs), leaving the app stuck in `Connecting` state indefinitely.

**Android 6 (API 24) minimum support:**

The commit `c379064` ("android 6") explicitly adds pre-Marshmallow BLE scan settings (`scanSettingsBeforeM` at line 185). This fallback uses `ScanSettings.Builder` without `setMatchMode` or `setNumOfMatches`, which means the scan is less aggressive on older devices. The minimum API level 24 means the codebase must handle both the legacy `BluetoothManager` API and the modern permission model.

**The `advertisingLocalValue` split (line 504-507):**

```typescript
this.advertisingLocalValue =
  Platform.OS === "ios"
    ? this.randomIntFromInterval(1, 124)
    : this.randomIntFromInterval(126, 249);
```

iOS devices advertise values 1-124, Android devices advertise 126-249. This is how the server election works: the higher number wins and becomes the GATT server. Since Android values are always higher, Android always becomes the server when paired with an iOS device. This is intentional -- iOS CoreBluetooth has limitations as a GATT server that Android does not.

### iOS-Specific

**400ms scan delay (line 716):**

```typescript
setTimeout(async () => {
  await this.blePlxManager.startDeviceScan(...)
}, Platform.OS === "ios" ? 400 : 0);
```

iOS requires a delay before starting BLE scanning after advertising. CoreBluetooth's `CBCentralManager` needs time to initialize and transition to the `poweredOn` state. Without this delay, the scan starts before the BLE stack is ready, resulting in silent failure. This 400ms value was determined empirically (there is no official Apple documentation specifying the required delay).

**`CBPeripheralManager` vs Central role:**

The iOS implementation (`BlePeripheralManager.swift`) uses `CBPeripheralManager` (Peripheral role) for the server side. The `react-native-ble-plx` library handles the Central role (scanning, connecting). iOS limits the number of concurrent peripheral connections, and `CBPeripheralManager` and `CBCentralManager` share the same Bluetooth radio. Running both simultaneously on iOS can cause contention.

**`value: nil` characteristic pattern (line 78-86 in BlePeripheralManager.swift):**

```swift
let charForRead = CBMutableCharacteristic(type: uuidCharForRead,
                                          properties: .read,
                                          value: nil,    // <-- nil value
                                          permissions: .readable)
```

On iOS, creating a `CBMutableCharacteristic` with `value: nil` means the characteristic has no initial value. The server responds to read requests dynamically (line 186-194). This is the iOS way of creating a "dynamic" characteristic. Android handles this differently -- the `onCharacteristicReadRequest` callback always returns the current advertised name.

**BLE state restoration (line 164-173 in BleManager.ts):**

```typescript
this.blePlxManager = new BlePlxManager({
  restoreStateIdentifier: "camdom-app",
  restoreStateFunction: (restoredState) => {
    if (restoredState) {
      this.connectedClients = restoredState.connectedPeripherals.map(
        (p) => p.id,
      );
    }
  },
});
```

iOS supports BLE state restoration, which allows the app to resume BLE operations after being terminated by the system. The `restoreStateIdentifier` tells CoreBluetooth to save the BLE state under this key. When the app relaunches, the `restoreStateFunction` receives the previously connected peripherals. The implementation only restores `connectedClients` -- it does not restore `sessionId`, `isServer`, or `appState`, meaning the restored connection is partially broken.

### Cross-Platform Inconsistencies

**MTU negotiation (line 909):**

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

MTU 53 bytes is chosen because:
- BLE 4.0 default MTU is 23 bytes (5 bytes header + 18 bytes payload)
- Negotiating to 53 gives 48 bytes of usable payload
- The base64-encoded protocol packets are typically under 48 bytes
- Higher MTU values are not reliably supported on older Android devices
- iOS ignores MTU requests entirely (CoreBluetooth negotiates automatically)

However, the code never checks if the negotiation succeeded. If the server rejects the MTU request, the default 23-byte MTU applies, and packets larger than 18 bytes (after base64 encoding overhead) will be silently truncated or cause write errors.

**Legacy scan (line 562):**

```typescript
legacyScan: true,
```

This flag tells the scan to use Android's legacy scan format instead of the modern extended advertisement format. It is set to `true` for backwards compatibility with older Android devices, but it means the scan cannot detect advertisements larger than 31 bytes.

**Advertising data format differences:**

Android's `advertiseData` (line 525-528 in BleManager.kt) includes the service UUID but NOT the device name (`setIncludeDeviceName(false)`). The device name is set separately via `bluetoothAdapter.setName(name)`. The scan response includes the device name (line 131-134 in BleManager.kt). iOS includes the device name in the advertisement itself (`CBAdvertisementDataLocalNameKey`).

This asymmetry means iOS devices are visible to scanners immediately, while Android devices only reveal their name after a scan response request. The code in BleManager.ts handles this by reading `device.localName` which react-native-ble-plx populates from either the advertisement or the scan response.

---

## 4. Error Handling & Recovery

### What Is Handled

| Error | Handling | Location |
|---|---|---|
| BLE state becomes PoweredOff/Unauthorized/Unsupported | Full state reset to disconnected, clear all arrays/maps | `onStateChange` (line 303-334) |
| Advertising failure | Snackbar error message, state reset, check iOS Bluetooth permission | `onStartAdvertising` (line 353-403) |
| Scan error | Snackbar, state reset to disconnected, stop scanning | Scan callback error (line 571-582) |
| Connection timeout | 7500ms `scanningTimeout` + 5000ms additional wait, then disconnect+retry | `configureScanTimeout` (line 720-768) |
| Write failure (sendPacketToServer) | If state is already `connected`/`synced`/`alarm`/`disconnected`, silently return; otherwise disconnect + state reset | `sendPacketToServer` (line 817-866) |
| Permission denied | `requestPermissions` flow with callback | `requestPermissions.ts` |
| Device connection error (GATT status != SUCCESS) | Android: logs error but does NOT disconnect (line 346-351, commented-out disconnect code). iOS: handled by react-native-ble-plx | `gattCallback.onConnectionStateChange` |
| Scan timeout (7500ms with no server found) | If not connecting and not already synced: reset to disconnected, disconnect, stop scan/advertising, restart scan | `configureScanTimeout` (line 720-768) |
| Sound loading failure | `isSoundsLoaded` flag prevents attempts to play | `bindSoundEffects` (line 177-215) |

### What Is NOT Handled

| Missing Error | Consequence | Severity |
|---|---|---|
| GATT 133 (random error on Android) | App stuck in Connecting state indefinitely. The TODO at line 344 says "close() and try reconnect" but the code is commented out. | High |
| GATT server crash (gattServer becomes null) | No recovery mechanism. The server simply stops responding. Clients will timeout. | High |
| Concurrent connection attempts | Two devices could both try to connect to each other simultaneously. The number-based election (higher = server) prevents this in theory, but race conditions during the election window can cause both devices to connect as clients. | Medium |
| Memory pressure from sound loading | `Promise.all` loads 5 audio files simultaneously (line 178). On low-memory devices, this could cause OOM. No try/catch around `Promise.all`. | Medium |
| Font loading failure | `useFonts` returns a nullable `fontMgr`. The `paragraph` derived value checks for null (line 638: `if (!fontMgr || !ballText) return null`), but a null paragraph causes the `Skia.Paragraph` component to render nothing -- no fallback text. | Low |
| Skia canvas rendering errors | No error boundary around the Canvas component. A rendering error would crash the entire app. | Medium |
| MMKV storage corruption | No integrity check on MMKV storage. If `rssiSense` or `fireAlarmOnDisconnect` become corrupted, the app could behave unpredictably. | Low |
| `onCamdomLogoPressed` partial state reset | The method (line 1757-1787) resets state but does not clear `sessionId`, `connectionPacketSent`, or `hasReceivedDisconnectFromServer`. This could leave the BleManager in a partially reset state. | Medium |
| `disconnectFromServer` swallows errors | The `cancelConnection()` call (line 793) is wrapped in a try/catch that does nothing with the error. If cancellation fails, `connectedDevice` is still set to undefined, but the actual connection may still exist. | Medium |

---

## 5. The Disconnect Protocol (State Machine)

The disconnect protocol is the most complex subsystem in CAMDOM. It uses a custom packet-based state machine with 7 distinct sub-states and 4 packet types.

### Packet Types

| Packet | Direction | Meaning |
|---|---|---|
| `d:r\|s` | Client -> Server | "I want to disconnect" (request start) |
| `d:r\|f` | Client -> Server | "I released the button" (request finish) |
| `d:r\|1` | Client -> Server | "I agree to disconnect" (consent) |
| `d:r\|ff` | Client -> Server | "Force disconnect" (edge case: server already in alarm) |
| `d:a\|r` | Server -> Client | "Disconnect rejected" |
| `d:a\|a` | Server -> Client | "All agreed, disconnect accepted" |
| `d:a\|a1` | Server -> Client | "Final disconnect confirmation" |
| `discq:{sessionId}` | Client -> Server | "Confirm disconnect queue" |
| `disca:1` | Server -> Client | "Disconnect queue confirmed" |
| `pong:{sessionId}` | Server -> Client | "Keepalive response" |
| `ping:{sessionId}` | Client -> Server | "Keepalive ping" |

### Complete Flow: Normal Disconnect

```
Phase 1: Initiation (Client holds disconnect button)
==========================================
Client: onPressIn -> onDisconnectPressStart()
  - isDisconnectButtonPressed = true
  - If server: start polling interval (500ms)
  - If client: sendPacketToServer("d:r|s")
  - Clear scanningTimeout

Server: receives "d:r|s"
  - Adds client to askedDisconnectionDevices[]

Phase 2: Polling (Server checks every 500ms)
==========================================
Server: serverDisconnectionTimeout fires
  - Checks: askedDisconnectionDevices.length === connectedClients.length
  - If ALL clients have requested disconnect:
    - Sets serverAlreadyShouldDisconnect = true
    - Broadcasts "d:a|a" to all clients in askedDisconnectionDevices
    - Clears interval

Client: receives "d:a|a"
  - Sends "d:r|1" back to server

Phase 3: Acceptance
==========================================
Server: receives "d:r|1" from client
  - Adds client to allowedClientsToDisconnect[]
  - If server button is still pressed AND all clients have agreed:
    - Broadcasts "d:a|a1" (final confirmation)
  - Otherwise:
    - Sends "d:a|a1" to the individual client

Client: receives "d:a|a1"
  - Sets hasReceivedDisconnectFromServer = true
  - Clears sessionId
  - Calls disconnectFromServer() -> plays unpair sound, resets state

Phase 4: Cleanup
==========================================
Server: onDisconnectPressFinish()
  - If no clients left or serverAlreadyShouldDisconnect:
    - Plays unpair sound
    - Resets state to disconnected
    - Calls stopAdvertising(true)
    - Clears all arrays, timers, and flags
```

### Edge Case: Server Backgrounds During Sync

```
Server app goes to background while synced:
  - AppState listener fires (line 257-276)
  - Server: plays alarm, sets appState="alarm", broadcasts "a:f|1|u"

Client: receives "a:f|1|u"
  - Sets isAmIClientAllowedToDisconnectEdgeCase = true
  - Sends "discq:{sessionId}" to confirm awareness
  - Sets appState="alarm"

Later, client tries to stop alarm:
  - onPressIn: sends "a:r|s" (alarm request start)
  - If isAmIClientAllowedToDisconnectEdgeCase:
    - Also sends "a:r|ff" (force disconnect finish)
    - Starts 1500ms pingPongTimeout to force disconnect
  - onPressOut: sends "a:r|f" (alarm request finish)
```

### Edge Case: Server Button vs Client Button Timing

When both the server and a client press their disconnect buttons simultaneously:

1. Server starts polling (500ms interval)
2. Client sends `d:r|s`
3. Server receives `d:r|s`, adds client to `askedDisconnectionDevices`
4. On next poll: `askedDisconnectionDevices.length (1) === connectedClients.length (1)` -> true
5. Server broadcasts `d:a|a`
6. Client receives `d:a|a`, sends `d:r|1`
7. Server receives `d:r|1`, but `isDisconnectButtonPressed` is still true and all clients have agreed
8. Server broadcasts `d:a|a1`
9. Both devices disconnect

This works correctly when the server has one client. With multiple clients, the server waits for ALL clients to agree before broadcasting `d:a|a1`. If one client refuses, the disconnect is stuck in limbo -- there is no timeout mechanism for the multi-device case.

---

## 6. The Alarm Trigger Paths

Every code path that leads to `appState = "alarm"`:

| # | Trigger | Who Fires | Code Location | Broadcast Packet |
|---|---|---|---|---|
| 1 | App backgrounded while synced | Server or Client | `bindListeners` AppState change (line 257-276) | Server: `a:f\|1\|u`, Client: `a:u\|i` |
| 2 | Android blur event while synced | Server or Client | `bindListeners` blur handler (line 279-300) | Server: `a:f\|1\|u`, Client: `a:u\|i` |
| 3 | Disconnect attempt with fireOnDisconnect=true | Server | `onDisconnectPressFinish` (line 1121-1130) | `a:f\|1\|a` |
| 4 | Client sends `d:r\|f` (release) and server has fireOnDisconnect | Server | `handlePacketFromClient` case `d`, `disconnectEventType === "f"` (line 1616-1623) | `a:f\|1\|a` |
| 5 | Client sends `d:r\|1` but was NOT in askedDisconnectionDevices | Server | `handlePacketFromClient` case `d`, `disconnectEventType === "1"` (line 1638-1649) | `a:f\|1\|a` |
| 6 | Server receives `a:u\|i` from client (backgrounding report) | Server | `handlePacketFromClient` case `a`, `alarmAction === "i"` (line 1700-1707) | `a:f\|1\|u` |
| 7 | Manual trigger via UI button | Client or Server | `onStopAlarmPressStart` (line 1227-1301) | Client: `a:r\|s`, Server: broadcasts when all agree |
| 8 | RSSI proximity (DESIGNED but COMMENTED OUT) | Server | `configureServerRSSITimeout` (line 1810-1851) | `a:f\|1` |

### The `fireAlarmOnDisconnect` Config

The `StorageKeys.fireAlarmOnDisconnect` flag is set via `s:r|{0|1}` packets. When a client connects (line 1340-1342):

```typescript
const shouldFire = storage.getBoolean(StorageKeys.fireAlarmOnDisconnect) ?? false;
this.sendPacketToServer(`s:r|${shouldFire ? 1 : 0}`);
```

This tells the server whether this client wants the alarm to fire if any device disconnects. Once any client sends `s:r|1`, the server sets `hasAnyoneRequestedToFireWhenDisconnect = true` permanently (line 1554-1556). There is no way to unset this flag during the session -- it is an AND operation, not a toggle.

---

## 7. Feature Completeness Matrix

| Feature | Designed | Implemented | Shipped | Notes |
|---|---|---|---|---|
| BLE mesh networking | Yes | Yes | Yes | Core feature, works cross-platform |
| Cross-platform (Android + iOS) | Yes | Yes | Yes | Android 6+ / iOS 13+ |
| GATT server/client election | Yes | Yes | Yes | Higher advertised value wins |
| Connection migration | Yes | Yes | Yes | Server role handoff via `m:d\|{value}` |
| Manual disconnect with consent | Yes | Yes | Yes | 5-sub-state protocol |
| Alarm on disconnect | Yes | Yes | Yes | Via manual/background triggers |
| Alarm sound sync | Yes | Yes | Yes | 5 sounds, server-broadcast (`sound:1\|{id}`) |
| i18n | Yes | Yes | Yes | EN + DE |
| Onboarding | Yes | Yes | Yes | 2-step flow |
| Privacy policy link | Yes | Yes | Yes | Options page |
| RSSI proximity alarm | Yes | Partial | **No** | Commented out in commit `2f701d2` |
| Sensitivity settings UI | Yes | Removed | **No** | Was in Options, now commented out |
| Sensitivity negotiation protocol | Yes | Partial | **No** | Server-side logic exists but is never invoked |
| `n:rssi\|1` activation broadcast | Yes | Partial | **No** | Code commented out |
| Automatic distance disconnect | Yes | Partial | **No** | Was in `configureServerRSSITimeout`, now commented |
| Sign-out button | Yes | Removed | **No** | Commented out in Options.tsx |
| Delete account button | Yes | Removed | **No** | Commented out in Options.tsx |
| Session replay analytics | Yes | Yes | **Removed** | Commit `be98dfc` removed it |
| NFC pairing | Yes | Partial | **No** | Commit `78e77f2` disabled it |
| iOS state restoration | Yes | Partial | **Partial** | Only restores connectedClients, not full state |

### The "Zombie Features"

Several features exist in a zombie state -- the storage keys are allocated, the handler logic exists in BleManager, but the UI that drives them has been removed or commented out:

- `rssiSense` (StorageKeys.ts) -- initialized to 60, never written by user
- `fireAlarmOnDisconnect` (StorageKeys.ts) -- initialized to false, but the server can set it from any client's `s:r` packet
- `hasAnyoneHigherSensibility` (BleManager.ts) -- compared but never populated (the code that would populate it from client RSSI packets is commented out)
- `distanceAlarmFired` (BleManager.ts) -- set to false on reset, never set to true
- `rssiUpdatesFromClients` (BleManager.ts) -- cleared on every state transition, never populated

---

## 8. Lessons Learned

### What Worked Well

**The Skia rendering pipeline.** Using `@shopify/react-native-skia` for the ball animations was the right call. The `useDerivedValue` chain that computes ball radius, position, and opacity from `appState` produces buttery 60fps animations without any frame drops. The `ImageShader` approach for the ball textures (red dot / black dot) is more performant than switching React components.

**The custom BLE protocol.** Building a custom GATT service with 4 characteristics (read, write, indicate, config) was the correct tradeoff. Standard BLE profiles (like GATT Battery Service or Heart Rate) do not support bidirectional packet exchange with multiple clients. The custom protocol allowed the server to broadcast to all clients and receive from any client without the overhead of standard GATT service discovery.

**The server election algorithm.** The number-based election (higher value = server) is simple, deterministic, and requires no coordination. iOS values are 1-124, Android values 126-249. This guarantees Android always wins when paired with iOS, avoiding the iOS-as-GATT-server limitations. The only edge case is two Android devices, where the first to start advertising wins.

**The disconnect consent protocol.** Despite its complexity, the protocol correctly implements mutual consent: both devices must agree to disconnect, and either can fire the alarm if the other tries to leave without agreement. The `press-and-hold` UI interaction prevents accidental disconnections.

**The sound broadcast system.** Broadcasting `sound:1|{id}` to all clients ensures all devices play the same sound at the same moment. The server-side `playSound` (line 247-249) only broadcasts non-alarm sounds, which is correct -- the alarm has its own broadcast path.

### What Was Harder Than Expected

**RSSI proximity was fundamentally flawed from the start.** The decision to use BLE RSSI as a distance proxy was reasonable in theory but impractical in reality. RSSI is not calibrated, not normalized, and not comparable across devices. The 14-day lifespan (introduced Aug 20, disabled Sep 3) confirms that the problems were immediately apparent in real-world testing.

**Cross-platform BLE state consistency.** Keeping the JS-level state (SharedValues + React state) synchronized with the native BLE stack state (GATT server lifecycle, connection status, scan state) across three threads (UI, JS, native) proved to be the hardest engineering challenge. The `useAnimatedReaction` bridge pattern works but introduces latency that creates race conditions.

**The disconnect protocol grew organically.** The `d:r|s` -> `d:a|a` -> `d:r|1` -> `d:a|a1` flow was built incrementally, with each edge case adding a new packet type or flag. The `discq`/`disca` keepalive mechanism was added later (there is no `discq` in the original v2.33.0 commit). The `isAmIClientAllowedToDisconnectEdgeCase` flag was added even later to handle the server-backgrounding case. This organic growth produced a protocol that works but is difficult to reason about.

**Sound loading on startup.** Loading 5 audio files via `Promise.all` in the constructor (line 174) means the BleManager is not ready until all sounds are loaded. The `isSoundsLoaded` guard prevents crashes, but there is a window where the manager is constructed but not functional. This caused subtle bugs where the first sound played before loading completed.

### What Would Be Done Differently

**Backend for device tracking.** The entire proximity system could have been replaced with a server-side solution. A lightweight server that tracks device locations (even approximately, via cell tower triangulation or WiFi BSSID) would have provided reliable distance estimation without the RSSI noise problem.

**Standard BLE profiles for pairing.** Using a standard BLE profile (like the Proximity Profile or Find Me Profile) would have provided RSSI reading and path loss estimation out of the box, with vendor-optimized implementations. The custom protocol was faster to build but lost access to these optimizations.

**TypeScript strict mode from day one.** The codebase has many implicit `any` types, particularly in the event handling (`(event as any).value`). Strict mode would have caught the type mismatches between the native event format and the TypeScript interfaces.

**Separate the protocol layer from the manager.** The BleManager class handles BLE lifecycle, protocol parsing, sound playback, haptic feedback, state management, and UI coordination in a single 1878-line file. Extracting the protocol layer (packet parsing, disconnect negotiation, alarm logic) into a separate class would make the system testable in isolation.

**State machine library instead of ad-hoc flags.** The disconnect protocol is a state machine but it is implemented as a collection of boolean flags (`isDisconnectButtonPressed`, `serverAlreadyShouldDisconnect`, `hasAnyoneRequestedToFireWhenDisconnect`, `isAmIClientAllowedToDisconnectEdgeCase`). A formal state machine (even a simple one) would make the transitions explicit and prevent invalid states.

### What Surprised

**The power of Skia for real-time UI.** The `ParagraphBuilder` API that renders text inside the Skia canvas was surprisingly effective. Building paragraphs with inline font styles and dynamic content inside a canvas, at 60fps, without React re-renders, was the single best technical decision in the project.

**The complexity of BLE state machines.** What started as "two phones connect, one plays alarm" became a 1878-line state machine with 7 connection states, 12+ packet types, 5 disconnect sub-states, and a server election algorithm. BLE is not a simple protocol, and the cross-platform constraints multiply the complexity.

**Android BLE quirks.** The `blur` event being distinct from `background`, the GATT 133 error being random and unrecoverable, the need for `legacyScan: true`, the advertising name being set via `bluetoothAdapter.setName()` instead of the advertisement data -- none of these are documented in any BLE tutorial. They were all discovered through testing.

**The 15ms RSSI polling rate was the minimum viable.** Before disabling, the RSSI system attempted 66fps polling on the client and 30fps aggregation on the server. Even at this rate, the data was too noisy to be useful. The lesson: BLE RSSI is not a sensor. It is a signal strength indicator that happens to correlate (poorly) with distance.

---

## Appendix A: Commit Archaeology

Key commits that shaped the current system:

| Commit | Date | Significance |
|---|---|---|
| `749d1c8` | Aug 20, 2024 | v2.33.0 -- Introduced proximity sensors, Options screen, fireAlarmOnDisconnect config |
| `2f701d2` | Sep 3, 2024 | "Disable proximity sensors" -- Commented out all RSSI-based alarm and disconnect logic |
| `0e9ed31` | Before 2f701d2 | "Final version - Animations, New Sounds, Alerts and better error handling" |
| `458183c` | After disable | "allow disconnection on special case when server closes the app" -- Added `isAmIClientAllowedToDisconnectEdgeCase` |
| `7b19e13` | After disable | "disable reconnection after alarm disconnect" |
| `964c74d` | After disable | "kill on alarm stop" |
| `78e77f2` | Later | "NFC Disable" -- Removed NFC pairing feature |
| `be98dfc` | Later | "remove session replay" -- Removed analytics |
| `82aefd5` | Later | "Alarm fixed on both devices" -- Major stability fix |

## Appendix B: The Commented-Out Code Map

Every commented-out section in the current BleManager.ts and what it was supposed to do:

| Lines | Original Function | Status |
|---|---|---|
| 1106-1113 | `n:rssi\|1` broadcast + snackbar + ping sound (onDisconnectPressFinish) | Commented out |
| 1198-1205 | Same as above (onDisconnectLongPress) | Commented out |
| 1268-1275 | Same as above (onStopAlarmPressStart) | Commented out |
| 1462-1469 | "Proximity Sensors Enabled" snackbar on ping sound | Commented out |
| 1481-1491 | RSSI notification handler in n: packet type | Commented out |
| 1577-1584 | `n:rssi\|1` broadcast (handlePacketFromClient case s) | Commented out |
| 1799-1803 | `a:u|rssi\|{rssi}\|{sense}` packet send (configureClientRSSITimeout) | Commented out |
| 1804 | Recursive `this.rssiTimeout = this.configureClientRSSITimeout()` | Commented out |
| 1814-1835 | Server-side RSSI aggregation and alarm firing | Commented out |
| 1837-1846 | Automatic disconnect after 1000ms of excessive RSSI | Commented out |
| 1849 | Recursive `this.rssiTimeout = this.configureServerRSSITimeout()` | Commented out |
| 1869 | RSSI timeout restart in `handleRssiUpdateFromClient` | Commented out |

**Total commented-out lines: ~80 lines** of proximity system code, still present in the codebase but never executed.
