WikifitaGitHub live67e8de5
outro · camdom/camdom-system-design

CAMDOM -- System Design: Delivered vs Conceptual

Gap analysis between designed and shipped: RSSI proximity (disabled), shared state dilemmas, GATT device handling, disconnect protocol, alarm trigger paths, feature completeness.

Baixar raw

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 100Androidanda100 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):

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

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

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

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

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

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

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

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

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

ErrorHandlingLocation
BLE state becomes PoweredOff/Unauthorized/UnsupportedFull state reset to disconnected, clear all arrays/mapsonStateChange (line 303-334)
Advertising failureSnackbar error message, state reset, check iOS Bluetooth permissiononStartAdvertising (line 353-403)
Scan errorSnackbar, state reset to disconnected, stop scanningScan callback error (line 571-582)
Connection timeout7500ms scanningTimeout + 5000ms additional wait, then disconnect+retryconfigureScanTimeout (line 720-768)
Write failure (sendPacketToServer)If state is already connected/synced/alarm/disconnected, silently return; otherwise disconnect + state resetsendPacketToServer (line 817-866)
Permission deniedrequestPermissions flow with callbackrequestPermissions.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-plxgattCallback.onConnectionStateChange
Scan timeout (7500ms with no server found)If not connecting and not already synced: reset to disconnected, disconnect, stop scan/advertising, restart scanconfigureScanTimeout (line 720-768)
Sound loading failureisSoundsLoaded flag prevents attempts to playbindSoundEffects (line 177-215)

What Is NOT Handled

Missing ErrorConsequenceSeverity
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 attemptsTwo 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 loadingPromise.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 failureuseFonts returns a nullable fontMgr. The paragraph derived value checks for null (line 638: `if (!fontMgr
Skia canvas rendering errorsNo error boundary around the Canvas component. A rendering error would crash the entire app.Medium
MMKV storage corruptionNo integrity check on MMKV storage. If rssiSense or fireAlarmOnDisconnect become corrupted, the app could behave unpredictably.Low
onCamdomLogoPressed partial state resetThe 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 errorsThe 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

PacketDirectionMeaning
d:r|sClient -> Server"I want to disconnect" (request start)
d:r|fClient -> Server"I released the button" (request finish)
d:r|1Client -> Server"I agree to disconnect" (consent)
d:r|ffClient -> Server"Force disconnect" (edge case: server already in alarm)
d:a|rServer -> Client"Disconnect rejected"
d:a|aServer -> Client"All agreed, disconnect accepted"
d:a|a1Server -> Client"Final disconnect confirmation"
discq:{sessionId}Client -> Server"Confirm disconnect queue"
disca:1Server -> 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":

#TriggerWho FiresCode LocationBroadcast Packet
1App backgrounded while syncedServer or ClientbindListeners AppState change (line 257-276)Server: a:f|1|u, Client: a:u|i
2Android blur event while syncedServer or ClientbindListeners blur handler (line 279-300)Server: a:f|1|u, Client: a:u|i
3Disconnect attempt with fireOnDisconnect=trueServeronDisconnectPressFinish (line 1121-1130)a:f|1|a
4Client sends d:r|f (release) and server has fireOnDisconnectServerhandlePacketFromClient case d, disconnectEventType === "f" (line 1616-1623)a:f|1|a
5Client sends d:r|1 but was NOT in askedDisconnectionDevicesServerhandlePacketFromClient case d, disconnectEventType === "1" (line 1638-1649)a:f|1|a
6Server receives a:u|i from client (backgrounding report)ServerhandlePacketFromClient case a, alarmAction === "i" (line 1700-1707)a:f|1|u
7Manual trigger via UI buttonClient or ServeronStopAlarmPressStart (line 1227-1301)Client: a:r|s, Server: broadcasts when all agree
8RSSI proximity (DESIGNED but COMMENTED OUT)ServerconfigureServerRSSITimeout (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):

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

FeatureDesignedImplementedShippedNotes
BLE mesh networkingYesYesYesCore feature, works cross-platform
Cross-platform (Android + iOS)YesYesYesAndroid 6+ / iOS 13+
GATT server/client electionYesYesYesHigher advertised value wins
Connection migrationYesYesYesServer role handoff via m:d|{value}
Manual disconnect with consentYesYesYes5-sub-state protocol
Alarm on disconnectYesYesYesVia manual/background triggers
Alarm sound syncYesYesYes5 sounds, server-broadcast (sound:1|{id})
i18nYesYesYesEN + DE
OnboardingYesYesYes2-step flow
Privacy policy linkYesYesYesOptions page
RSSI proximity alarmYesPartialNoCommented out in commit 2f701d2
Sensitivity settings UIYesRemovedNoWas in Options, now commented out
Sensitivity negotiation protocolYesPartialNoServer-side logic exists but is never invoked
n:rssi|1 activation broadcastYesPartialNoCode commented out
Automatic distance disconnectYesPartialNoWas in configureServerRSSITimeout, now commented
Sign-out buttonYesRemovedNoCommented out in Options.tsx
Delete account buttonYesRemovedNoCommented out in Options.tsx
Session replay analyticsYesYesRemovedCommit be98dfc removed it
NFC pairingYesPartialNoCommit 78e77f2 disabled it
iOS state restorationYesPartialPartialOnly 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:

CommitDateSignificance
749d1c8Aug 20, 2024v2.33.0 -- Introduced proximity sensors, Options screen, fireAlarmOnDisconnect config
2f701d2Sep 3, 2024"Disable proximity sensors" -- Commented out all RSSI-based alarm and disconnect logic
0e9ed31Before 2f701d2"Final version - Animations, New Sounds, Alerts and better error handling"
458183cAfter disable"allow disconnection on special case when server closes the app" -- Added isAmIClientAllowedToDisconnectEdgeCase
7b19e13After disable"disable reconnection after alarm disconnect"
964c74dAfter disable"kill on alarm stop"
78e77f2Later"NFC Disable" -- Removed NFC pairing feature
be98dfcLater"remove session replay" -- Removed analytics
82aefd5Later"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:

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

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