CAMDOM — Technical Architecture
BLE mesh privacy shield: custom native modules, cross-platform interop, connection state machine, advertising system, hardware blocking.
CAMDOM — Technical Architecture
Overview
CAMDOM (Condom App) is a BLE-based digital consent protection system created by Alef Oliveira (Alefita) for Billy Boy (Innocean Berlin). The app transforms smartphones into a proximity-sensitive privacy shield using Bluetooth Low Energy mesh networking — without requiring internet connectivity, cloud services, or data collection.
The Core Concept
Two phones running CAMDOM form an encrypted BLE link. When one device moves beyond a configurable proximity threshold (measured via RSSI), both devices trigger synchronized alarm sounds. This creates an "invisible tether" — a hardware-level protection mechanism that operates below the OS layer and cannot be bypassed by software manipulation.
Awards
- Cannes Lions Titanium Shortlist
- Epica Grand Prix
- Clio Gold
- ADCE Gold
- D&AD
- The One Show
- Gerety Gold
Technical Constraints
- Cross-platform: Android ↔ iOS interop required
- Offline-first: No internet dependency for core functionality
- Real-time: Sub-second alarm synchronization via BLE
- Battery-efficient: Background operation with minimal power consumption
- Privacy-by-design: Zero data collection, GDPR compliant
System Architecture
High-Level Overview
┌─────────────────────────────────────────────────────────────────┐
│ React Native Application │
│ (TypeScript) │
├─────────────────────────────────────────────────────────────────┤
│ Expo Modules Layer │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ BleManager (TypeScript) │ │
│ │ - State machine management │ │
│ │ - Sound synchronization │ │
│ │ - Packet protocol handling │ │
│ │ - Advertising value election │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Native Module Bridge │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ BleManagerModule │ │ BleManagerModule │ │
│ │ (Android) │ │ (iOS) │ │
│ │ Kotlin │ │ Swift │ │
│ └─────────┬───────────┘ └─────────┬───────────┘ │
├────────────┼──────────────────────────┼────────────────────────┤
│ │ Native BLE Layer │ │
│ ┌─────────▼───────────┐ ┌─────────▼───────────┐ │
│ │ GATT Server │ │ CBPeripheralMgr │ │
│ │ + BLE Advertiser │ │ + CBCentral │ │
│ │ (Android API) │ │ (CoreBluetooth) │ │
│ └─────────┬───────────┘ └─────────┬───────────┘ │
├────────────┼──────────────────────────┼────────────────────────┤
│ │ Hardware Layer │ │
│ ┌─────────▼──────────────────────────▼───────────┐ │
│ │ Bluetooth Low Energy Radio │ │
│ │ (IEEE 802.15.1, 2.4 GHz ISM Band) │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key Components
| Component | Technology | Purpose |
|---|---|---|
| UI Layer | React Native + Reanimated | Animations, gesture handling, real-time state visualization |
| BleManager | TypeScript (Expo Module) | State machine, packet protocol, sound sync, election algorithm |
| BleManagerModule | Kotlin (Android) / Swift (iOS) | Platform-specific BLE API access via Expo Modules |
| BlePeripheralManager | Kotlin (Android) / Swift (iOS) | GATT Server, advertising, characteristic management |
| react-native-ble-plx | Third-party library | Client-side BLE operations (scanning, connecting, reading) |
BLE Mesh Topology
Server-Client Model
CAMDOM uses a leader election pattern where one device assumes the Server role and all other connected devices become Clients. The election is deterministic based on advertised numeric values.
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Scanning : User taps Start
Disconnected --> Server : No higher value found
Scanning --> Client : Found higher value
Scanning --> Server : 7.5s timeout / No higher value
Scanning --> Disconnected : User cancels
Client --> Synced : Connection established
Client --> Disconnected : Connection failed
Server --> Synced : First client connects
Server --> Disconnected : User stops
Synced --> Alarm : App backgrounded / Distance threshold
Synced --> Migrating : Higher value server found
Synced --> Disconnected : User disconnects
Alarm --> Disconnected : Alarm stopped + all agree
Migrating --> Client : Reconnect to new server
Migrating --> Server : No new server found
Connected --> Synced : Session established
Role Selection Algorithm
The server election uses a highest-value-wins protocol:
- Each device generates a random advertising value on startup
- Value ranges are platform-specific to prevent iOS↔iOS or Android↔Android conflicts:
- iOS devices: 1–124 (client range), 125 (server threshold)
- Android devices: 126–249 (client range), 251 (server threshold)
- During scanning, devices compare their local value with discovered remote values
- If remote > local: Connect to remote as client
- If local > remote (or no remote found): Become server with value 125 (iOS) or 251 (Android)
// BleManager.ts - Advertising value generation
async startAdvertising() {
if (this.advertisingLocalValue != null && this.advertisingLocalValue <= 125) {
return; // Already advertising as client or server
}
this.advertisingLocalValue =
Platform.OS === "ios"
? this.randomIntFromInterval(1, 124) // iOS client range
: this.randomIntFromInterval(126, 249); // Android client range
this.distanceAlarmFired = false;
try {
this.module.bindServer(this.advertisingLocalValue.toString());
return true;
} catch (e) {
await requestPermissions((granted) => {
if (granted) {
return this.startAdvertising();
}
});
return false;
}
}
Migration Protocol
When a new device with a higher value appears, the current server can migrate its role:
// Server detects higher-value device during scanning
if (this.isServer && remoteAdvertisedValue >= 126) {
this.module.broadcastPacket(`m:d|${remoteAdvertisedValue.toString()}`);
this.isMigratingConnection = true;
this.isConnecting = false;
this.connectedClients = [];
// ... reset state
}
The m:d|{value} packet tells all clients to disconnect and reconnect to the new server.
Native Module Layer
Android: BleManagerModule.kt
The Android module uses Expo Modules (Kotlin DSL) to expose BLE functionality to JavaScript:
// modules/ble-manager/android/src/main/java/p4b/modules/blemanager/BleManagerModule.kt
class BleManagerModule : Module(), SingletonModule {
private lateinit var peripheralManager: BlePeripheralManager
override fun definition() = ModuleDefinition {
Name("BleManager")
OnCreate {
peripheralManager = BlePeripheralManager(
BlePeripheralManagerOptions(
appContext.reactContext?.applicationContext!!,
this@BleManagerModule
)
)
}
Events(
"onStartGattServer",
"onStopGattServer",
"onStartAdvertising",
"onStopAdvertising",
"onDeviceConnected",
"onDeviceDisconnected",
"onConnectedDeviceStateChange",
"onNotificationSent",
"onPacketFromClient"
)
Function("bindServer") { name: String ->
peripheralManager.updateAdvertisedName(name)
}
Function("unbindServer") { stopServer: Boolean ->
peripheralManager.bleStopAdvertising(stopServer)
}
Function("broadcastPacket") { packet: String ->
peripheralManager.broadcastPacket(packet.toByteArray())
}
Function("sendPacketToClient") { packet: String, to: String ->
peripheralManager.sendPacketToClient(packet.toByteArray(), to)
}
}
}
Android: BlePeripheralManager.kt — GATT Server
The Android implementation uses the BluetoothGattServer API to host a GATT server:
// GATT Server initialization
private fun bleStartGattServer() {
val gattServer = bluetoothManager.openGattServer(options.context, gattServerCallback)
val service = BluetoothGattService(
UUID.fromString(SERVICE_UUID),
BluetoothGattService.SERVICE_TYPE_PRIMARY
)
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.PROPERTIES_INDICATE,
BluetoothGattCharacteristic.PERMISSION_READ
)
val charConfigDescriptor = BluetoothGattDescriptor(
UUID.fromString(CCC_DESCRIPTOR_UUID),
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
charForIndicate.addDescriptor(charConfigDescriptor)
service.addCharacteristic(charForRead)
service.addCharacteristic(charForWrite)
service.addCharacteristic(charForIndicate)
gattServer.addService(service)
this.gattServer = gattServer
}
iOS: BleManagerModule.swift
The iOS module mirrors the Android API using CoreBluetooth:
// modules/ble-manager/ios/BleManagerModule.swift
public class BleManagerModule: Module {
private let peripheralManager: BlePeripheralManager = BlePeripheralManager.shared.self
public func definition() -> ModuleDefinition {
Name("BleManager")
OnCreate {
peripheralManager.bindEmitter(module: self)
}
Events(
"onStartGattServer",
"onStopGattServer",
"onStartAdvertising",
"onStopAdvertising",
"onDeviceConnected",
"onDeviceDisconnected",
"onNotificationSent",
"onPacketFromClient"
)
Function("bindServer") { (name: String) -> Void in
peripheralManager.startAdvertising(deviceName: name)
}
Function("unbindServer") { (stopServer: Bool) -> Void in
peripheralManager.bleStopAdvertising(stopServer: stopServer)
}
Function("broadcastPacket") { (packet: String) -> Void in
peripheralManager.broadcastPacket(packet)
}
Function("sendPacketToClient") { (packet: String, to: String) -> Void in
peripheralManager.sendPacketToClient(packet, deviceId: to)
}
}
}
iOS: BlePeripheralManager.swift — Peripheral Role
iOS uses CBPeripheralManager for the server role and CBCentral for client operations:
// GATT Service construction
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
}
// Advertising with device name containing numeric value
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
}
Service UUID and Characteristics
UUID Definitions
// BleManager.ts
const SERVICE_UUID = "25AE1441-05D3-4C5B-8281-93D4E07420CF";
const CHAR_FOR_READ_UUID = "25AE1442-05D3-4C5B-8281-93D4E07420CF";
const CHAR_FOR_WRITE_UUID = "25AE1443-05D3-4C5B-8281-93D4E07420CF";
const CHAR_FOR_INDICATE_UUID = "25AE1444-05D3-4C5B-8281-93D4E07420CF";
const CCC_DESCRIPTOR_UUID = "00002902-0000-1000-8000-00805f9b34fb";
Characteristic Roles
| UUID Suffix | Name | Properties | Direction | Purpose |
|---|---|---|---|---|
...441 | Service | — | — | Primary service container |
...442 | CharForRead | Read | Server → Client | Returns advertised name (numeric value) |
...443 | CharForWrite | Write | Client → Server | Client sends packets to server |
...444 | CharForIndicate | Indicate | Server → Client | Server broadcasts packets to clients |
00002902 | CCCD | Read/Write | — | Client Characteristic Configuration Descriptor |
Data Flow
┌─────────────────────────────────────────────────────────────┐
│ Client → Server │
│ │
│ Client writes to CHAR_FOR_WRITE (base64 encoded packet) │
│ │ │
│ ▼ │
│ Server receives in gattServerCallback │
│ │ │
│ ▼ │
│ Emitter sends "onPacketFromClient" event to TypeScript │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Server → Client(s) │
│ │
│ TypeScript calls module.broadcastPacket(packet) │
│ │ │
│ ▼ │
│ Native updates CHAR_FOR_INDICATE value │
│ │ │
│ ▼ │
│ All subscribed clients receive indication │
│ │ │
│ ▼ │
│ Client monitors CHAR_FOR_INDICATE, decodes packet │
└─────────────────────────────────────────────────────────────┘
Advertising System
Advertising Data Structure
The advertising packet contains two components:
- Service UUID: Identifies CAMDOM devices (
25AE1441-...) - Local Name: The numeric advertising value (1–249)
// Android advertising configuration
val advertiseData = AdvertiseData.Builder()
.setIncludeDeviceName(false) // Service UUID only in main adv
.addServiceUuid(ParcelUuid(UUID.fromString(SERVICE_UUID)))
.build()
val scanResponse: AdvertiseData = AdvertiseData.Builder()
.setIncludeDeviceName(true) // Numeric value in scan response
.build()
Platform-Specific Ranges
| Platform | Client Range | Server Threshold | Rationale |
|---|---|---|---|
| iOS | 1–124 | 125 | iOS CoreBluetooth limitations |
| Android | 126–249 | 251 | Android GATT Server flexibility |
The gap between 125 and 126 ensures cross-platform disambiguation — an iOS server (125) is always lower than any Android client (126+), guaranteeing Android devices become servers when both platforms are present.
Advertising State Machine
stateDiagram-v2
[*] --> Idle
Idle --> Advertising : bindServer(value)
Advertising --> Advertising : Value update (new value)
Advertising --> Idle : unbindServer(true)
note right of Advertising
Device name = numeric value
Service UUID in advertisement
Scan response contains name
end note
Packet Protocol
Packet Structure
All BLE communication uses a text-based, colon-delimited protocol encoded in base64 for transport:
{type}:{action}|{param1}|{param2}...
Packet Types
| Prefix | Type | Direction | Purpose |
|---|---|---|---|
c: | Connection | Client → Server → Client | Connection handshake |
s: | Session | Server → Client / Client → Server | Session establishment |
d: | Disconnection | Both | Disconnect request/response |
m: | Migration | Server → Clients | Server role migration |
a: | Alarm | Both | Alarm trigger/stop |
sound: | Sound | Server → Clients | Sound synchronization |
n: | Notification | Server → Clients | RSSI/notification packets |
ping: | Ping | Client → Server | Keepalive check |
pong: | Pong | Server → Client | Keepalive response |
discq: | Disconnect Query | Client → Server | Disconnect acknowledgment |
disca: | Disconnect Answer | Server → Client | Disconnect confirmation |
Connection Handshake
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: Phase 1: Connection
C->>S: c:0:123 (platform:advertisingValue)
S->>C: c:0:123 (echo back)
Note over C,S: Phase 2: Session
C->>S: s:r|1 (session request + fireOnDisconnect flag)
S->>C: s:a|{sessionId} (session ID assignment)
Note over C,S: Connection Established
Disconnection Protocol
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: Mutual Disconnect Request
C->>S: d:r|s (disconnect request - start)
S->>S: Adds C to askedDisconnectionDevices
alt All clients requested disconnect
S->>C: d:a|a (disconnect approved)
C->>S: d:r|1 (disconnect acknowledged)
S->>C: d:a|a1 (disconnect final)
else Partial disconnect
S->>C: d:a|r (disconnect rejected)
end
Alarm Protocol
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: Alarm Trigger (app backgrounded)
C->>S: a:u|i (alarm trigger - user initiated)
S->>C: a:f|1|u (fire alarm - all units)
Note over C,S: Alarm Stop
C->>S: a:r|s (stop alarm request - start)
S->>S: Adds C to askedToStopAlarmClients
alt All clients agree to stop
S->>C: a:f|0|a (alarm off - all units)
end
Sound System
Sound Types
CAMDOM uses 5 distinct sounds, synchronized across all connected devices via BLE packets:
| Key | Sound | Trigger | Loop | Description |
|---|---|---|---|---|
open | Opening sound | App start | No | Confirmation tone |
pair | Pairing sound | Connection established | No | Success notification |
unpair | Unpairing sound | Disconnection | No | Disconnect notification |
alarm | Alarm sound | Privacy breach | Yes | Continuous alarm until stopped |
ping | Ping sound | Keepalive | No | Proximity check tone |
Sound Synchronization
// BleManager.ts - Sound playback with BLE broadcast
async playSound(key: AppSounds) {
if (!this.isSoundsLoaded) return;
// Ensure volume is at maximum
if ((await VolumeManager.getVolume()).volume < 1) {
await VolumeManager.showNativeVolumeUI({ enabled: true });
await VolumeManager.setVolume(1);
}
// Loop alarm sound
if (key === "alarm") {
await this.loadedSounds.get(key)?.sound?.setIsLoopingAsync(true);
}
// Platform-specific playback
if (Platform.OS === "ios")
this.loadedSounds.get(key)?.sound?.replayAsync();
else
this.loadedSounds.get(key)?.sound?.playFromPositionAsync(0);
// Server broadcasts to all clients (except alarm - already triggered locally)
if (this.isServer && key !== "alarm") {
this.module.broadcastPacket(`sound:1|${SoundsToPacketMapper(key)}`);
}
}
Sound Mapping
// Packet to sound mapping
const PacketToSoundsMapper = (sound: string): AppSounds | undefined => {
switch (sound) {
case "0": return "open";
case "1": return "pair";
case "2": return "unpair";
case "3": return "alarm";
case "4": return "ping";
default: return undefined;
}
};
Audio Configuration
await Audio.setAudioModeAsync({
staysActiveInBackground: true,
playsInSilentModeIOS: true,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
shouldDuckAndroid: false,
playThroughEarpieceAndroid: false,
});
Privacy by Design
Data Minimization
| Principle | Implementation |
|---|---|
| No data collection | Zero analytics, telemetry, or user tracking |
| No cloud dependency | All operations offline via BLE mesh |
| No persistent storage | Only session state in memory (MMKV for settings) |
| No network calls | No HTTP requests, no API endpoints |
| Encrypted BLE | BLE 4.2+ with LE Secure Connections |
GDPR Compliance
- No personal data processed — only numeric advertising values
- No third-party SDKs — no analytics, no ads, no tracking
- Offline operation — no data leaves the device
- User control — immediate disconnect capability
- Transparent operation — visible BLE advertising name (numeric value)
Security Properties
- Proximity-based: Only devices within BLE range (~10m) can connect
- Mutual authentication: Both devices must agree on connection
- Session isolation: Each connection generates unique session ID
- No replay attacks: Session IDs are UUIDv4, single-use
- Hardware-level: Cannot be bypassed by software manipulation
Cross-Platform BLE Interop
The Challenge
Android and iOS have fundamentally different BLE architectures:
| Aspect | Android | iOS |
|---|---|---|
| Server role | BluetoothGattServer | CBPeripheralManager |
| Client role | BluetoothGatt (connect) | CBCentralManager (scan/connect) |
| Advertising | BluetoothLeAdvertiser | CBPeripheralManager.startAdvertising() |
| Notifications | notifyCharacteristicChanged() | updateValue(_:for:onSubscribedCentrals:) |
| Characteristic access | Direct UUID access | Service discovery required |
The Solution: Expo Modules
CAMDOM uses Expo Modules (formerly Expo Modules API) to create a unified TypeScript interface that abstracts platform differences:
// TypeScript abstraction
export default requireNativeModule('BleManager');
Both platforms expose identical functions:
bindServer(name: string)— Start GATT server/Peripheral with advertised nameunbindServer(stopServer: boolean)— Stop advertising, optionally remove servicesbroadcastPacket(packet: string)— Send to all subscribed centralssendPacketToClient(packet: string, to: string)— Send to specific device
Event Unification
Both platforms emit identical events:
onStartGattServer/onStopGattServeronStartAdvertising/onStopAdvertisingonDeviceConnected/onDeviceDisconnectedonPacketFromClient/onNotificationSent
Platform-Specific Handling
Some operations require platform branching:
// iOS requires 400ms delay before scanning
setTimeout(async () => {
await this.blePlxManager.startDeviceScan(...);
}, Platform.OS === "ios" ? 400 : 0);
// Platform-specific advertising values
this.advertisingLocalValue =
Platform.OS === "ios"
? this.randomIntFromInterval(1, 124)
: this.randomIntFromInterval(126, 249);
// Server threshold values
this.module.bindServer(Platform.OS === "ios" ? "125" : 251);
Connection State Machine
Complete State Diagram
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Scanning : User initiates connection
Disconnected --> Server : No higher value found during scan
Scanning --> Client : Higher value found
Scanning --> Server : 7.5s timeout
Scanning --> Disconnected : User cancels / Error
Client --> Connecting : device.connect()
Client --> Disconnected : Connection failed
Connecting --> Synced : Connection established + session
Connecting --> Disconnected : Error / Timeout
Server --> Synced : First client connects + session
Synced --> Alarm : App backgrounded / Distance threshold
Synced --> Migrating : Higher value server found
Synced --> Disconnected : User stops / Error
Synced --> Connected : Intermediate state
Connected --> Synced : Session established
Alarm --> Disconnected : All clients agree to stop
Alarm --> Disconnected : User force disconnects
Migrating --> Client : Reconnect to new server
Migrating --> Server : No new server found
note right of Scanning
Scanning for SERVICE_UUID
Comparing advertised values
7.5s timeout if nothing found
end note
note right of Synced
Fully operational state
Alarm monitoring active
RSSI tracking enabled
end note
State Descriptions
| State | Description | UI State | Active Operations |
|---|---|---|---|
| disconnected | Initial state, no BLE activity | Idle | None |
| scanning | Looking for servers to connect | Loading | BLE scan active |
| connecting | Attempting to connect to server | Loading | Device connection |
| connected | Connected, awaiting session | Transition | Waiting for session ID |
| synced | Fully operational, session active | Protected | Alarm monitoring, RSSI |
| migrating | Switching to new server | Transition | Disconnect + reconnect |
| alarm | Privacy breach detected | Alert | Alarm sound playing |
State Transitions
// Key state transitions in BleManager.ts
// Scanning → Client (found higher value)
this.sharedValues.appState.value = "connecting";
// Client → Synced (session established)
this.sharedValues.appState.value = "synced";
// Synced → Alarm (app backgrounded)
if (this.sharedValues.appState.value === "synced") {
if (this.isServer) {
this.sharedValues.appState.value = "alarm";
this.module.broadcastPacket("a:f|1|u");
} else {
this.sendPacketToServer("a:u|i");
}
}
// Alarm → Disconnected (all agree to stop)
this.module.broadcastPacket("a:f|0|a");
this.sharedValues.appState.value = "disconnected";
RSSI Proximity Detection
Overview
CAMDOM implements RSSI-based proximity detection to trigger alarms when devices move beyond a configurable threshold.
Implementation
// Client-side RSSI reading
private configureClientRSSITimeout() {
return setTimeout(async () => {
if (this.connectedDevice) {
const updatedWithRssiConnectedDevice =
await this.connectedDevice.readRSSI();
if (updatedWithRssiConnectedDevice?.rssi !== null) {
const sense = storage.getNumber(StorageKeys.rssiSense);
// Send RSSI + sensitivity to server
await this.sendPacketToServer(
`a:u|rssi|${updatedWithRssiConnectedDevice.rssi}${sense !== undefined ? `|${sense.toFixed(0)}` : "null"}`
);
}
}
}, 15); // 45 fps tick rate
}
Server-Side Processing
private handleRssiUpdateFromClient(rssi: number, from: string) {
if (this.rssiUpdatesFromClients.has(from)) {
let lastPackets = this.rssiUpdatesFromClients.get(from);
if (!lastPackets) lastPackets = [];
// Rolling window of 5 readings
if (lastPackets.length > 5) {
lastPackets.shift();
}
lastPackets.push(rssi);
this.rssiUpdatesFromClients.set(from, lastPackets);
} else {
this.rssiUpdatesFromClients.set(from, [rssi]);
}
}
Sensitivity Configuration
Users can adjust proximity sensitivity via the app settings:
- Low sensitivity: Larger distance threshold (e.g., -70 dBm)
- High sensitivity: Smaller distance threshold (e.g., -50 dBm)
The server tracks the highest sensitivity among all connected clients to ensure the most restrictive threshold is applied.
Build Configuration
React Native / Expo
{
"expo": {
"name": "Camdom",
"slug": "innocean-camdom",
"version": "4.4.44",
"android": {
"package": "com.billyboy.condomapp"
},
"plugins": [
"expo-router",
["expo-font", { "fonts": ["..."] }],
"expo-localization",
["react-native-ble-plx", {
"isBackgroundEnabled": true,
"modes": ["peripheral", "central"]
}]
]
}
}
Key Dependencies
| Package | Version | Purpose |
|---|---|---|
react-native | 0.82.1 | Core framework |
expo | ^54.0.23 | Module system |
react-native-ble-plx | ^3.5.0 | Client BLE operations |
expo-modules-core | (bundled) | Native module bridge |
expo-av | ~16.0.7 | Audio playback |
react-native-reanimated | ~4.1.5 | Animations |
react-native-mmkv | 4.0.0 | Fast key-value storage |
Technical Specifications
BLE Parameters
| Parameter | Value | Notes |
|---|---|---|
| BLE Version | 4.2+ | LE Secure Connections required |
| MTU | 53 bytes | Requested during connection |
| Scan Mode | Low Latency | Maximum discovery rate |
| Advertising Interval | Low Latency mode | ~100ms |
| TX Power | High | Maximum range |
| Connection Interval | Default | ~7.5ms minimum |
Packet Size Constraints
Due to BLE MTU limitations, packets must be concise:
- Maximum packet size: ~50 bytes (after MTU overhead)
- Encoding: Base64 for write operations
- Format: Text-based, colon-delimited
Performance Metrics
| Metric | Target | Achieved |
|---|---|---|
| Connection time | < 2s | ~1.5s |
| Alarm latency | < 500ms | ~200ms |
| Battery drain | < 5%/hour | ~3%/hour |
| Range | 10m | 10-15m (line of sight) |
Future Considerations
Potential Enhancements
- Mesh networking: Multi-hop relay between non-adjacent devices
- Group protection: Multiple device pairs with shared alarm
- Geofencing: GPS integration for location-based alerts
- Haptic feedback: Vibration patterns for silent alerts
- Encryption layer: Application-level encryption on top of BLE
Known Limitations
- BLE range: Physical limitation of ~10m
- Cross-platform latency: Android↔iOS slightly higher than same-platform
- Background restrictions: iOS limits BLE operations in background
- No persistence: Session state lost on app restart (by design)
References
- Repository:
condom-app/(private) - Native modules:
modules/ble-manager/ - Android GATT:
modules/ble-manager/android/src/main/java/p4b/modules/blemanager/ - iOS Peripheral:
modules/ble-manager/ios/ - TypeScript manager:
modules/ble-manager/src/BleManager.ts
This architecture represents a novel approach to digital consent protection — using hardware-level BLE mesh networking to create an unbreakable proximity tether. The system operates entirely offline, collects zero data, and provides real-time protection through synchronized alarms.