Spec: P2P Bluetooth Messenger
Status: Draft v0.1 — April 2026 Prototype: 2 of 6 Platform: Android only (API 21+, API 31+ recommended) Scope: 1-to-1 BLE messaging, no groups, no feeds, no internet
Overview
This document is the engineering specification for Prototype 2: a peer-to-peer Bluetooth messenger that runs entirely on Android with no internet and no server. Two phones advertise their presence using BLE beacons, discover each other automatically, exchange public keys via QR code on first meeting, and from that point forward exchange encrypted messages directly over Bluetooth GATT whenever they are within range. Messages that arrive when the recipient is out of range are queued locally and delivered on the next encounter. The prototype is described in plain language in Story 2: The Festival and listed alongside the other prototypes on the Research Prototypes page.
The primary goal is empirical: put two phones in a room, make them find each other, send an encrypted message, and measure what actually happens — range, throughput, latency, battery draw, and failure modes. Everything here is informed by the Feed Format Spec, which defines the BLE sync wire protocol (SyncRequest / SyncOffer / SyncChunk / SyncAck, 244-byte chunks with Data Length Extension, big-endian multi-byte integers) that the full Connect system will use. This prototype implements a simplified subset of that protocol sufficient to validate the transport layer.
API Surface / Key Interfaces
All interfaces are Kotlin pseudocode. Actual implementation will use Android SDK types directly; these signatures define the contract between components.
GATT UUIDs
object ConnectBleUuids {
// Service exposed by every device acting as a GATT server
val SERVICE: UUID = UUID.fromString("00001234-0000-1000-8000-00805f9b34fb")
// Writable characteristic: peer writes message chunks to this
val MSG_WRITE: UUID = UUID.fromString("00001235-0000-1000-8000-00805f9b34fb")
// Properties: WRITE | WRITE_NO_RESPONSE
// Max value length: negotiated MTU − 3 bytes (ATT overhead)
// Notify characteristic: server pushes message chunks to subscribed peers
val MSG_NOTIFY: UUID = UUID.fromString("00001236-0000-1000-8000-00805f9b34fb")
// Properties: NOTIFY
// Requires Client Characteristic Configuration Descriptor (CCCD) write to enable
// Readable characteristic: 32-byte X25519 public key for this device
val PUBKEY_READ: UUID = UUID.fromString("00001237-0000-1000-8000-00805f9b34fb")
// Properties: READ
// Value: raw 32-byte X25519 public key (not base64, raw bytes over the wire)
}
BleAdvertiser
Wraps BluetoothLeAdvertiser. Broadcasts the Connect service UUID so nearby scanners can identify this device without connecting.
data class BeaconPayload(
val serviceUuid: UUID = ConnectBleUuids.SERVICE,
// 16 bytes of manufacturer-specific data:
// [0..1] protocol version (uint16, big-endian) — currently 0x0001
// [2..9] truncated SHA-256 of identity public key (first 8 bytes)
// used by known contacts to recognise each other without revealing full identity
// [10..15] reserved, zero
val manufacturerData: ByteArray
)
interface BleAdvertiser {
/**
* Start advertising. Throws [BleUnsupportedException] if hardware does not
* support BLE advertising (most handsets do; some cheap tablets do not).
*
* @param beaconPayload Payload to embed in the advertisement packet.
* @param mode One of ADVERTISE_MODE_LOW_POWER (default, ~1 Hz),
* ADVERTISE_MODE_BALANCED (~3 Hz), or
* ADVERTISE_MODE_LOW_LATENCY (~10 Hz, only for foreground bursts).
* @throws BleUnsupportedException Hardware does not support multi-advertisement.
* @throws BlePermissionException BLUETOOTH_ADVERTISE permission not granted.
*/
fun start(beaconPayload: BeaconPayload, mode: Int = ADVERTISE_MODE_LOW_POWER)
/**
* Stop advertising immediately. Safe to call if not currently advertising.
*/
fun stop()
/**
* True if currently advertising. Check before calling start() again.
*/
val isAdvertising: Boolean
// Failure modes:
// - ADVERTISE_FAILED_ALREADY_STARTED: call stop() first
// - ADVERTISE_FAILED_TOO_MANY_ADVERTISERS: other apps hold slots; retry after delay
// - ADVERTISE_FAILED_FEATURE_UNSUPPORTED: device cannot advertise (rare on modern Android)
// - SecurityException on API 31+ if BLUETOOTH_ADVERTISE not in manifest + granted at runtime
}
BleScanner
Wraps BluetoothLeScanner. Discovers devices advertising the Connect service UUID.
data class ScanResult(
val device: BluetoothDevice,
val rssi: Int, // signal strength in dBm; typical range: −40 (close) to −90 (edge of range)
val manufacturerData: ByteArray?, // raw manufacturer-specific bytes from advertisement
val timestampNanos: Long
)
interface BleScanner {
/**
* Begin scanning for devices advertising ConnectBleUuids.SERVICE.
* Results are delivered on the calling thread's Looper (wrap in coroutine if needed).
*
* @param callback Invoked for every scan result. May be called frequently; debounce in caller.
* @param scanMode ScanSettings.SCAN_MODE_LOW_POWER (default) or SCAN_MODE_BALANCED.
* Do not use SCAN_MODE_LOW_LATENCY in background; Android will kill the service.
* @throws BlePermissionException BLUETOOTH_SCAN not granted.
*/
fun startScan(callback: (ScanResult) -> Unit, scanMode: Int = ScanSettings.SCAN_MODE_LOW_POWER)
/**
* Stop scanning. Must be called when the foreground service pauses to avoid battery drain.
*/
fun stopScan()
// Failure modes:
// - Scan silently produces no results if location permission is missing (Android 10 and below).
// On API 29+ BLUETOOTH_SCAN is separate from ACCESS_FINE_LOCATION, but both may be needed.
// - On some Samsung devices, scan results stop arriving after ~30 minutes without stopping
// and restarting the scan (known stack bug).
// - Android 7+ limits background scan starts to 5 per 30 seconds from a single app.
// Use a single long-running scan rather than repeated start/stop.
}
GattServer
Runs a GATT server that peers connect to in order to write and receive message chunks.
data class GattHandle(
val server: BluetoothGattServer,
val service: BluetoothGattService
)
interface GattServer {
/**
* Open the GATT server and register the Connect service with MSG_WRITE, MSG_NOTIFY,
* and PUBKEY_READ characteristics.
*
* @return GattHandle References to the server and registered service.
* @throws GattServerException Bluetooth adapter unavailable or already in use.
*/
fun start(): GattHandle
/**
* Stop the server and disconnect all connected clients.
*/
fun stop()
/**
* Send a notification chunk to a connected peer.
* Must be called only after the peer has written 0x0100 to the CCCD for MSG_NOTIFY.
*
* @param device Target peer.
* @param chunk Raw bytes, max length = negotiated MTU − 3.
* @return True if the notification was queued successfully.
*/
fun notifyChunk(device: BluetoothDevice, chunk: ByteArray): Boolean
/**
* Callback invoked when a peer writes to MSG_WRITE.
* Register before calling start().
*/
var onChunkReceived: ((device: BluetoothDevice, chunk: ByteArray) -> Unit)?
// Failure modes:
// - BluetoothGattServer.addService() may return false silently if called before adapter is ready.
// Wait for BluetoothAdapter.STATE_ON before calling start().
// - notifyChunk() returns false if the notification queue is full (16 pending on some devices).
// Back off and retry with exponential delay.
// - On API 33+, BLUETOOTH_CONNECT permission required at runtime; missing it throws
// SecurityException with a confusing message.
}
GattClient
Connects to a remote GATT server and exchanges message chunks.
interface GattClient {
/**
* Connect to a remote device's GATT server.
* Discovers services and characteristics automatically.
* Connection is asynchronous; use the returned Deferred or observe state changes.
*
* @param device BluetoothDevice from a ScanResult.
* @return Deferred<Unit> that completes when services are discovered and ready.
* @throws GattConnectionException Connection timed out or device refused.
*/
suspend fun connect(device: BluetoothDevice): Unit
/**
* Request an MTU increase. Call after connect() and before sending.
* Android default ATT MTU is 23 bytes (20-byte payload). Target 512 bytes.
* In practice, most modern devices negotiate 247 bytes (244-byte payload with DLE).
*
* @param mtu Requested MTU in bytes (23–512).
* @return Actual negotiated MTU (may be lower than requested).
*/
suspend fun requestMtu(mtu: Int = 512): Int
/**
* Subscribe to MSG_NOTIFY on the remote server by writing 0x0100 to its CCCD.
* Incoming chunks are delivered via onChunkReceived.
*/
suspend fun subscribeToNotifications()
/**
* Send a message as a sequence of chunks, each sized to negotiated MTU − 3 bytes.
* The framing protocol prefixes each transfer with a header chunk:
* [0] 0x01 = SyncChunk frame type
* [1..4] transfer ID (uint32, big-endian) — random per message
* [5..8] total byte length of the encrypted payload (uint32, big-endian)
* [9..10] total chunk count (uint16, big-endian)
* Subsequent chunks:
* [0] 0x02 = data chunk
* [1..4] transfer ID (same as header)
* [5..6] chunk index (uint16, big-endian, zero-based)
* [7..] payload bytes
*
* @param chunks Pre-fragmented byte arrays; caller is responsible for sizing to MTU.
* @throws GattWriteException Write failed or connection dropped mid-transfer.
*/
suspend fun sendMessage(chunks: List<ByteArray>)
/**
* Read the remote device's X25519 public key from the PUBKEY_READ characteristic.
* Used on first meeting to bootstrap the key exchange without QR code.
*
* @return 32-byte raw X25519 public key.
*/
suspend fun readRemotePublicKey(): ByteArray
/**
* Disconnect and release all GATT resources.
*/
fun disconnect()
/** Callback for incoming notification chunks from the remote server. */
var onChunkReceived: ((chunk: ByteArray) -> Unit)?
// Failure modes:
// - GATT connection attempt silently times out on some Xiaomi devices (MIUI Bluetooth stack).
// Set a 10-second timeout and retry once before giving up.
// - requestMtu() callback may return a lower MTU than requested; always use the returned value.
// - Writes to a characteristic with WRITE_NO_RESPONSE can be dropped silently if the remote
// buffer is full. Use WRITE with response for the header chunk; WRITE_NO_RESPONSE for data.
// - Service discovery (discoverServices()) must complete before any read/write; check
// BluetoothGatt.GATT_SUCCESS in onServicesDiscovered.
}
MessageStore
SQLite-backed queue (Room) for messages pending delivery. Survives app restarts.
data class Message(
val id: String, // UUID v4
val contactId: String, // SHA-256(contact's identity public key), hex
val ciphertext: ByteArray, // encrypted payload, never stored as plaintext
val timestampMs: Long,
val direction: Direction, // OUTBOUND or INBOUND
val delivered: Boolean
)
enum class Direction { OUTBOUND, INBOUND }
interface MessageStore {
/**
* Add an outbound message to the queue for a contact.
* Ciphertext must be provided; plaintext is never written to the store.
*
* @param contactId Identifier of the intended recipient.
* @param ciphertext Already-encrypted payload bytes.
*/
fun enqueue(contactId: String, ciphertext: ByteArray)
/**
* Return all pending (undelivered) outbound messages for a contact, ordered by timestamp.
* Used when a contact comes into BLE range.
*
* @param contactId Contact to fetch pending messages for.
* @return List of undelivered messages, oldest first.
*/
fun dequeue(contactId: String): List<Message>
/**
* Mark a message as delivered. Removes it from the pending queue.
*
* @param messageId ID returned from enqueue / stored in Message.
*/
fun markDelivered(messageId: String)
/**
* Store an inbound message received from a contact.
* Ciphertext is stored; decryption happens at display time, not at receipt.
*
* @param contactId Sender's contact ID.
* @param ciphertext Encrypted payload exactly as received over GATT.
*/
fun storeInbound(contactId: String, ciphertext: ByteArray)
/**
* Return all inbound messages from a contact, ordered by timestamp.
*/
fun getInbound(contactId: String): List<Message>
// Failure modes:
// - Room database must be opened on a non-main thread; use withContext(Dispatchers.IO).
// - ByteArray columns in Room require a @TypeConverter; missing this causes a crash at build time.
// - Database file is in the app's private data directory; survives app updates, deleted on uninstall.
}
CryptoLayer
Wraps libsodium (via kalium or lazysodium-android) for key generation and authenticated encryption.
data class KeyPair(
val publicKey: ByteArray, // 32 bytes (X25519)
val privateKey: ByteArray // 32 bytes (X25519), kept in memory only, never written to disk
)
interface CryptoLayer {
/**
* Generate a fresh X25519 keypair for this device.
* Private key is held in memory (or Android Keystore on API 23+); never serialised.
*
* @return KeyPair with 32-byte public and private keys.
*/
fun generateKeyPair(): KeyPair
/**
* Derive a shared secret from our private key and the remote party's public key (X25519 DH).
* The shared secret is then used as the key for encrypt/decrypt.
*
* @param ourPrivateKey Our X25519 private key.
* @param theirPublicKey Remote party's X25519 public key (32 bytes).
* @return 32-byte shared secret (not the raw DH output; HKDF-SHA256 applied).
*/
fun deriveSharedSecret(ourPrivateKey: ByteArray, theirPublicKey: ByteArray): ByteArray
/**
* Encrypt a plaintext message for a specific recipient using XChaCha20-Poly1305.
* Uses the pre-derived shared secret, not raw public keys.
*
* Layout of returned ciphertext:
* [0..23] 24-byte random nonce
* [24..] XChaCha20-Poly1305 ciphertext + 16-byte Poly1305 MAC
*
* @param plaintext UTF-8 encoded message text.
* @param sharedSecret 32-byte shared secret from deriveSharedSecret().
* @return Nonce-prefixed authenticated ciphertext.
* @throws CryptoException libsodium not initialised or key length wrong.
*/
fun encrypt(plaintext: ByteArray, sharedSecret: ByteArray): ByteArray
/**
* Decrypt and authenticate a ciphertext received from a contact.
*
* @param ciphertext Nonce-prefixed ciphertext as returned by encrypt().
* @param sharedSecret Shared secret derived from the sender's public key.
* @return Original plaintext bytes.
* @throws AuthenticationException MAC verification failed — message tampered or wrong key.
* @throws CryptoException Malformed ciphertext (too short for nonce).
*/
fun decrypt(ciphertext: ByteArray, sharedSecret: ByteArray): ByteArray
// Design notes:
// - XChaCha20-Poly1305 is chosen over AES-GCM for consistent performance on devices without
// AES hardware acceleration. All modern Android devices have AES-NI, but older API 21 devices
// may not. XChaCha20 is constant-time in software.
// - The nonce is random (not counter-based) because we do not have a reliable message ordering
// guarantee at the GATT layer. Counter-based nonces require sequence number tracking.
// - This prototype uses static keys (no ratchet). A production implementation would use
// Double Ratchet (Signal Protocol) for forward secrecy. That is explicitly out of scope here.
// - Shared secrets are never written to disk. They are derived fresh on each app startup from
// stored public keys + the device's persisted private key.
}
Dependencies & Libraries (with versions)
| Library | Version | Purpose | License |
|---|---|---|---|
| Android BLE APIs (built-in) | API 21+ (BluetoothLeAdvertiser, BluetoothLeScanner, BluetoothGattServer, BluetoothGattCallback) |
Core BLE advertising, scanning, and GATT server/client | Apache 2.0 (part of AOSP) |
lazysodium-android |
5.1.4 | JVM/Android bindings for libsodium — X25519 DH, XChaCha20-Poly1305, HKDF | LGPL-2.1 |
tink-android (Google Tink) |
1.13.0 | Alternative to lazysodium; higher-level crypto API with Android Keystore integration | Apache 2.0 |
zxing-android-embedded |
4.3.0 | QR code generation (display own public key) and scanning (read contact's key) | Apache 2.0 |
| Room (AndroidX) | 2.6.1 | SQLite ORM for message queue persistence; survives process death | Apache 2.0 |
kotlinx-coroutines-android |
1.8.1 | Async GATT operations (connect, requestMtu, read, write) as suspend functions | Apache 2.0 |
react-native-ble-plx |
3.4.0 | Alternative path only: React Native BLE library wrapping native Android (and iOS) BLE APIs | Apache 2.0 |
| BitChat (reference) | commit a3f2c1d (Apr 2024) |
Reference implementation of BLE GATT messaging on Android; not a dependency — study only. Royal Holloway analysis (2024) identified broken authentication and replay vulnerabilities in their BLE layer. Do not ship BitChat's crypto code. | MIT |
| Berty/weshnet | v0.0.32 | Alternative path: gomobile-compiled BLE transport from the Berty project. Adds ~12 MB to APK and significant gomobile binding complexity. Evaluate only if raw BLE proves too brittle. | Apache 2.0 |
| Zemzeme (reference) | v1.0.2 (Apr 2026) | Open-source Android BLE mesh messenger (BitChat fork + libp2p). Kotlin. Architecture reference only. | MIT |
Note on tink-android vs lazysodium-android: Tink offers a cleaner API and better Android Keystore integration for private key storage, but does not expose raw X25519 directly — it wraps it inside HPKE or ECDH-based constructs. For this prototype, lazysodium-android gives more direct control over the primitives. Production code should prefer Tink.
Platform Constraints
This section documents constraints that will affect implementation decisions. Read before writing any BLE code.
Minimum SDK and API levels
- API 21 (Android 5.0): Minimum for BLE peripheral mode (
BluetoothLeAdvertiser). Without this, a device can only scan, not advertise. - API 26 (Android 8.0):
BluetoothLeScannerscan filters became more reliable. Strongly recommended as effective minimum for development. - API 31 (Android 12): New granular Bluetooth permissions split from location:
BLUETOOTH_SCAN,BLUETOOTH_CONNECT,BLUETOOTH_ADVERTISE. Apps targeting API 31+ must request these at runtime. Code must handle both old and new permission models. - API 33 (Android 13): No additional BLE changes, but foreground service types became stricter —
foregroundServiceType="connectedDevice"required in manifest.
Target SDK for this prototype: API 34 (Android 14). Min SDK: API 26.
Background advertising and Android Doze
From Android 8+ (Oreo), background BLE operations require a foreground service with a persistent notification. Without it:
- Scanning is throttled to ~15 minutes, then silently stopped.
- Advertising may continue briefly, but the process can be killed at any time.
- On Android 12+, the foreground service must declare
foregroundServiceType="connectedDevice"inAndroidManifest.xml.
Battery optimiser: Users (and OEM ROM variants, especially MIUI and One UI) can kill foreground services. The app must handle unexpected service death gracefully, re-queue pending messages, and restart advertising when the app returns to foreground.
Advertisement mode and battery trade-offs
| Mode | Advertise interval | Current draw (approx.) | Use case |
|---|---|---|---|
ADVERTISE_MODE_LOW_POWER |
~1000 ms | ~1–2 mA | Background continuous presence |
ADVERTISE_MODE_BALANCED |
~250 ms | ~3–5 mA | Foreground, moderate discovery speed |
ADVERTISE_MODE_LOW_LATENCY |
~100 ms | ~10–15 mA | Active pairing flow only, never in background |
Default to LOW_POWER for background. Switch to BALANCED when the app is foregrounded and the user is actively trying to find contacts. Never use LOW_LATENCY outside of a brief user-initiated scan burst (e.g., the "Find contacts" button).
MTU and fragmentation
- ATT default MTU: 23 bytes. Payload per write: 23 − 3 (ATT header) = 20 bytes.
- After MTU negotiation (requestMtu): Most modern Android devices support up to 512 bytes ATT MTU.
- With Data Length Extension (DLE, API 21+): Physical layer packet size increases from 27 to 251 bytes. Combined with MTU negotiation, practical payload per write: 244 bytes at
ATT_MTU = 247. - Practical ceiling: Some devices report MTU 512 but the controller silently limits to 247. Measure actual throughput; do not trust the negotiated value blindly.
- Fragmentation rule: All messages larger than
(negotiatedMtu - 3)bytes must be split into chunks. TheGattClient.sendMessage(chunks)interface handles this; the caller callsfragment(payload, mtu)first.
fun fragment(payload: ByteArray, mtu: Int): List<ByteArray> {
val chunkSize = mtu - 3 - 7 // 7 bytes for chunk framing header
return payload.toList().chunked(chunkSize).map { it.toByteArray() }
}
Known Android Bluetooth stack bugs
These are real issues encountered in the field and in BitChat / Zemzeme development:
- Samsung (One UI 5+): GATT connection attempts occasionally fail with
GATT_ERROR (133)on the first try. Retry after 500 ms delay; second attempt succeeds ~90% of the time. - Xiaomi (MIUI 12–14): Background scan results stop arriving after 20–30 minutes due to aggressive process management. Advertise-only mode continues working. Workaround: restart scan every 15 minutes from the foreground service.
- Pixel (Android 13):
onMtuChangedcallback sometimes reports incorrect (lower) MTU value. Re-request MTU once more if the first negotiation returns 23. - All manufacturers: Concurrent GATT connections are limited. Android supports up to 7 simultaneous connections in theory; in practice, stability degrades above 3–4. For 1-to-1 prototype, this is not a problem, but note it for future multi-contact use.
- BLE advertising slots: Some chipsets limit concurrent advertisers to 3–4. If other apps are advertising heavily (e.g., Nearby Share), the
start()call may fail withADVERTISE_FAILED_TOO_MANY_ADVERTISERS. - BluetoothGatt.close() must be called: Leaking a
BluetoothGattobject prevents future connections to the same device. Always callgatt.close()in the disconnect path, including error paths.
iOS — explicitly excluded
iOS CoreBluetooth cannot advertise in the background without workarounds. Specifically:
CBPeripheralManageris suspended when the app enters the background.- The OS only resumes it for brief intervals when a known central device (one that has previously connected) comes into range.
- Advertisement data is stripped by the OS; central-mode devices cannot see custom service UUIDs in background scan results.
CBCentralManagerscan results in background are coalesced and delayed.
These constraints make it impossible to build a symmetric "both devices advertise and scan in background" model on iOS without platform-specific workarounds. The iOS BLE deep dive at /ios-ble/ covers this in full. Prototype 2 is Android only. iOS is a separate, harder problem.
BLE range expectations
- Indoors, through walls: 5–15 m reliable range with
LOW_POWERadvertising. - Open space, line of sight: 30–100 m with
BALANCEDorLOW_LATENCY, depending on chipset. - The festival story (30 m at a crowded outdoor event) is realistic with
BALANCEDmode. - RSSI of approximately −75 dBm or better is needed for a stable GATT connection. Treat anything below −85 dBm as unreliable for data transfer.
Battery impact
A foreground service running continuous BLE scan and advertise in LOW_POWER mode draws approximately 8–15% battery per hour on a 4000 mAh device under real-world conditions. Duty cycling (advertise 10s on / 20s off) can reduce this to ~4–6% at the cost of ~3x slower discovery.
For the prototype, measure actual drain using adb shell dumpsys batterystats over a 1-hour run and report the result as a success metric.
Build & Test Instructions
Prerequisites
- Android Studio Iguana (2023.2.1) or later
- Two physical Android devices — emulators do not support real BLE
- Both devices on API 26+ (API 31+ preferred)
- USB cables for both (or adb over WiFi for one after initial setup)
adbon the development machine'sPATH
Project setup
# Create a new Android project
# File > New Project > Empty Activity
# Language: Kotlin
# Minimum SDK: API 26 (Android 8.0 Oreo)
# Target SDK: 34
# Add to app/build.gradle.kts dependencies block:
dependencies {
implementation("net.java.dev.jna:jna:5.14.0@aar")
implementation("com.goterl:lazysodium-android:5.1.4@aar")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
Required permissions in AndroidManifest.xml
<!-- API 18-30: BLE requires location permission -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- API 31+ granular Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Required for foreground service on API 26+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Declare BLE as required -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<!-- Foreground service declaration (inside <application>) -->
<service
android:name=".BleMessengerService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
Build and deploy
# Build debug APK
./gradlew assembleDebug
# Install on device A (ensure only one device connected, or use -s <serial>)
adb install app/build/outputs/apk/debug/app-debug.apk
# Install on device B
adb -s <device-B-serial> install app/build/outputs/apk/debug/app-debug.apk
# Verify BLE advertising support on each device
adb shell dumpsys bluetooth_manager | grep -i "advertis"
# Monitor logs from both devices simultaneously (two terminals)
adb logcat -s ConnectBLE:V
adb -s <device-B-serial> logcat -s ConnectBLE:V
Unit tests
Run on the development machine (no device needed):
# GATT fragmentation and reassembly
./gradlew test --tests "*.GattFragmentationTest"
# Tests: fragment() produces correct chunk count, final chunk is correct size,
# reassembly from out-of-order chunks produces original payload,
# single-chunk message (payload < MTU) works correctly.
# Crypto encrypt/decrypt round-trip
./gradlew test --tests "*.CryptoLayerTest"
# Tests: encrypt/decrypt round-trip, MAC failure on tampered ciphertext throws
# AuthenticationException, wrong key throws AuthenticationException,
# nonce is random (two encryptions of same plaintext differ).
# MessageStore persistence
./gradlew test --tests "*.MessageStoreTest"
# Tests: enqueue then dequeue returns same ciphertext, markDelivered removes from queue,
# dequeue on unknown contactId returns empty list.
Integration tests — two physical devices
These require both devices connected over adb:
# Test 1: Discovery
# Run on device A: start advertising
# Run on device B: start scanning
# Expected: device B finds device A within 15 seconds (LOW_POWER mode)
# Measure: time from scan start to first callback with ConnectBleUuids.SERVICE UUID
adb logcat -s ConnectBLE:V | grep "DISCOVERED"
# Test 2: GATT connection and MTU negotiation
# Connect device B to device A's GATT server
# Expected: connection succeeds, MTU negotiates to >= 200 bytes
adb logcat -s ConnectBLE:V | grep "MTU_NEGOTIATED"
# Test 3: Message exchange
# Device A enqueues a 1 KB message for device B
# Bring devices within 5 m; observe automatic connection and delivery
# Verify on device B: message received and decrypts to original plaintext
adb logcat -s ConnectBLE:V | grep -E "SENT|RECEIVED|DECRYPTED"
Measurement tests
Run each measurement test for a full duration and record output to a file:
# Battery drain over 1 hour (foreground service, LOW_POWER mode, no active transfers)
adb shell dumpsys batterystats --reset
# Wait 60 minutes
adb shell dumpsys batterystats | grep -A5 "com.connect.prototype"
# RSSI vs distance measurements
# Place devices at 1m, 5m, 10m, 20m, 30m intervals
# Record RSSI from ScanResult at each distance (3 measurements, take median)
# Log to CSV: distance_m, rssi_dbm, connection_stable (bool)
# Throughput: time to transfer a 10 KB payload over GATT
# Measure from sendMessage() call to onChunkReceived() completing on receiver
# Record: negotiated MTU, total chunks, elapsed ms, throughput KB/s
adb logcat -s ConnectBLE:V | grep "TRANSFER_COMPLETE"
Success Criteria
The prototype is considered successful when all of the following are met and measured on two physical Android devices:
- Discovery speed: Two phones running in foreground discover each other within 15 seconds at 5 metres, with
ADVERTISE_MODE_BALANCEDscanning. - Background discovery: Two phones running background foreground services discover each other within 60 seconds at 5 metres, with
ADVERTISE_MODE_LOW_POWER. - GATT MTU negotiation: MTU negotiates to at least 200 bytes on both test devices. Record actual negotiated value.
- Message delivery — small message: A 256-byte encrypted message is delivered end-to-end (including GATT transfer and decryption) within 5 seconds of connection establishment.
- Message delivery — 1 KB message: A 1 KB encrypted message is delivered within 10 seconds of connection establishment, with correct fragmentation and reassembly.
- Store-and-forward: A message queued while the recipient is out of range is automatically delivered when the recipient next comes within BLE range, with no user action required. Message content is identical to what was sent.
- Persistence across restart: The message queue survives an app process kill and device reboot. Pending messages are re-queued and delivered on next encounter.
- No plaintext on disk: A search of the app's private data directory (
/data/data/com.connect.prototype/) confirms no message plaintext is stored. AllMessagerows in the Room database contain only ciphertext. - Battery drain: Foreground service with continuous background BLE (scan + advertise,
LOW_POWER) draws less than 15% battery per hour on a 4000 mAh device, measured viadumpsys batterystats. - Crypto correctness: A message encrypted by device A decrypts correctly on device B. A tampered ciphertext (one bit flipped) fails decryption with
AuthenticationException— not silent corruption. - QR key exchange flow: Two phones can complete the public key exchange via QR code scan in under 30 seconds from tapping the "Add contact" button to contact stored.
- Range characterisation: RSSI and connection stability documented at 1m, 5m, 10m, 20m, 30m. Connection stable at ≥10m indoors on both test devices.
Risks & Unknowns
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Android Bluetooth stack fragmentation — different bugs on Samsung vs Pixel vs Xiaomi | High | Medium | Test on at least two different OEM devices. Document per-device workarounds. Wrap all GATT operations in a retry layer with exponential backoff. |
| Background foreground service killed by Android Doze / battery optimiser (especially on Xiaomi MIUI) | High | High | Require user to exempt app from battery optimisation during setup. Handle service restart gracefully. Re-queue any in-progress transfers on restart. |
| BLE connection drops before full message transfer completes | High | Medium | Persist transfer state (transfer ID + chunks received so far) in Room. On reconnect, resume from last acknowledged chunk using SyncAck-style protocol. |
| MTU negotiation fails or returns 23 on older devices | Medium | Medium | Always call requestMtu(512) after connect. If result is 23, continue with 20-byte chunks (slower but correct). Record which devices hit this. |
GATT STATUS_133 / GATT_ERROR connection failures (Samsung-specific) |
High (Samsung) | Low | Retry connection once after 500 ms. Log frequency. If failure rate exceeds 20%, implement a connection watchdog that forces reconnect. |
| Discovery timing — both devices scanning simultaneously may miss each other if they are not both advertising | Medium | High | Always advertise and scan simultaneously. Android supports concurrent peripheral + central role on all devices with API 21+ and BLE 4.0+. Verify dual-mode works on test devices. |
| QR key exchange UX friction — users must actively scan a QR code on first meeting | Medium | Medium | Supplement QR code with PUBKEY_READ GATT characteristic so keys can be exchanged automatically over BLE on first connection (lower security — trust-on-first-use). Document the security trade-off. |
| Security issues in BitChat's BLE implementation if forked | High (if forked) | High | Do not fork BitChat for the crypto or authentication layer. Use it only as a reference for GATT service structure. Implement crypto independently using lazysodium. Apply Royal Holloway analysis findings. |
| libsodium initialisation failure on some Android devices | Low | High | Call LazySodium.init() at Application startup, not lazily. Wrap in try/catch and surface an error UI if it fails — the app cannot safely operate without crypto. |
| Prototype scope creep — temptation to add mesh relay, groups, or feed sync | Medium | Medium | Scope is explicitly 1-to-1, no relay, no groups, no feeds. Defer all additions to later prototypes. Write scope boundaries in code as TODO comments with prototype numbers. |
| Continuous BLE drain exceeding battery tolerance for a festival use case | Medium | Medium | Measure actual drain in success criteria. If > 15%/hour, implement duty cycling (10s on / 20s off) as a configurable option and re-measure. |
2-Week Day-by-Day Build Plan
| Day | Goal | Deliverable | Dependencies |
|---|---|---|---|
| 1 | Project setup, permissions, BLE adapter check | Android project compiles, Bluetooth adapter detected, permissions granted on first launch | Android Studio, two test devices |
| 2 | BLE advertising + scanning working, two phones see each other | BleAdvertiser.start() and BleScanner.startScan() functional; log shows "DISCOVERED device X" within 15 s on device B |
Day 1 complete |
| 3 | GATT server up on device A, device B connects | GattServer.start() registers service; GattClient.connect() succeeds; onServicesDiscovered fires on device B |
Day 2 complete |
| 4 | MTU negotiation + raw byte write from B to A | requestMtu(512) called; actual MTU logged; device B writes 10 bytes to MSG_WRITE; device A's onChunkReceived fires |
Day 3 complete |
| 5 | Bidirectional: device A notifies device B | GattServer.notifyChunk() sends bytes to B; B's subscribeToNotifications() + onChunkReceived receives them; both directions confirmed |
Day 4 complete |
| 6 | Chunking and reassembly for messages > MTU | fragment() utility, chunk framing header (transfer ID, total length, chunk index), reassembly buffer; 10 KB payload transfers correctly |
Day 5 complete |
| 7 | Key generation + QR code display and scan | CryptoLayer.generateKeyPair() on each device; QR code displays own public key; QR scan stores contact's public key; contact record saved to Room |
Day 6 complete |
| 8 | X25519 DH shared secret derivation | CryptoLayer.deriveSharedSecret() produces identical 32-byte value on both devices after key exchange; verified in unit test and on-device log |
Day 7 complete |
| 9 | Encrypt before GATT send, decrypt on receive | CryptoLayer.encrypt() wraps plaintext before sendMessage(); receiver calls CryptoLayer.decrypt() in onChunkReceived; plaintext reconstructed correctly end-to-end |
Day 8 complete |
| 10 | MAC verification — reject tampered messages | Flip one bit in ciphertext; verify AuthenticationException thrown, message silently discarded, UI shows no new message; logged as warning |
Day 9 complete |
| 11 | MessageStore persistence + store-and-forward | MessageStore.enqueue() persists pending outbound messages; BLE reconnect triggers dequeue() and automatic send; verified across app restart |
Day 10 complete |
| 12 | Foreground service for background BLE | BleMessengerService runs as foreground service with persistent notification; app backgrounded, two devices brought into range, message delivered; foreground service restart handled |
Day 11 complete |
| 13 | Full end-to-end test + measurement run | Run all success criteria tests; record RSSI vs distance, MTU values, battery drain, transfer timing; note any device-specific bugs encountered | Days 1–12 complete, both test devices available for 2+ hours |
| 14 | Document findings, write bug report, note deferred scope | findings.md in the repo: measured values for all success criteria, list of Android stack bugs encountered, recommended changes for the full system, open questions for Prototype 3 |
Day 13 data |
Architecture Summary
┌────────────────────────────────────────────────────────┐
│ Device A │
│ │
│ ┌──────────────┐ ┌───────────────┐ │
│ │ BleAdvertiser│ │ BleScanner │ │
│ │ (broadcast) │ │ (find peers) │ │
│ └──────┬───────┘ └──────┬────────┘ │
│ │ │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌───────────────┐ ┌──────────────┐ │
│ │ GattServer │◄──►│ GattClient │ │
│ │ (MSG_WRITE, │ │ (connect, │ │
│ │ MSG_NOTIFY, │ │ sendMessage)│ │
│ │ PUBKEY_READ) │ └──────────────┘ │
│ └───────┬───────┘ │
│ │ chunks │
│ ┌───────▼───────┐ │
│ │ CryptoLayer │ │
│ │ (X25519+Poly) │ │
│ └───────┬───────┘ │
│ │ ciphertext │
│ ┌───────▼───────┐ │
│ │ MessageStore │ │
│ │ (Room/SQLite)│ │
│ └───────────────┘ │
└────────────────────────────────────────────────────────┘
BLE air interface (GATT)
┌────────────────────────────────────────────────────────┐
│ Device B (symmetric — same stack) │
└────────────────────────────────────────────────────────┘
Relation to the Feed Format Spec
The Feed Format Spec defines the full BLE sync wire protocol for the Connect system: SyncRequest, SyncOffer, SyncChunk, SyncAck message types, 244-byte chunk sizes with Data Length Extension, and big-endian multi-byte integers. This prototype implements a simplified subset: SyncChunk framing only (transfer ID, total length, chunk index), without the request/offer negotiation phase. The nonce-prefixed ciphertext layout and the chunk framing byte order are intentionally compatible with the full spec so that code written here can be incorporated directly into the full implementation.