Offline mesh SOS relay for signal-dead zones. Hacks 11.0 — Problem Statement #15, Disaster Management.
Every phone running SafeHop is one hop in a chain that gets a message out — one span of a bridge built from strangers' phones.
Live demo & APK download: safehop.strawhats.co.in
During severe floods, cyclones, or earthquakes, mobile cell towers frequently collapse, leaving stranded victims unable to send emergency SOS requests even when rescue teams are nearby.
When the tower dies, so does every app on the phone. But the phone still has two radios that never needed a tower: Bluetooth Low Energy and Wi-Fi Direct. In a flooded apartment block there are dozens of phones within a hundred metres of each other, and at least one of them is usually close enough to something with a working uplink.
The devices are already there. Nothing is using them.
Most attempts at this build a chat app and put a red button on it. That misreads the problem.
A disaster mesh is not a bandwidth problem. It is an energy-allocation problem. Every byte forwarded costs joules that a stranded person may need in order to stay reachable. SafeHop routes battery, not packets.
Everything below falls out of that one sentence — including the answer to the challenge question, which is not a feature bolted on at the end but the reason the protocol is shaped the way it is.
A complete, routable SOS is 24 bytes — exactly the usable payload of a BLE legacy advertisement:
31 bytes AD payload in a legacy advertisement
− 3 bytes Flags AD structure (length + type + data)
− 4 bytes Service Data AD structure (length + type + 16-bit UUID)
─────────
= 24 bytes usable ← the entire SafeHop beacon
Relaying is re-advertising. A node hears a beacon, decrements its TTL, and puts it back on the air. No connection. No pairing. No handshake. No GATT. The sender never learns the relay exists.
This matters because connectionless broadcast is the cheapest thing a phone radio can do. Published measurements on BLE silicon put continuous scanning near 40 mW against roughly 600 µW for advertising — about two orders of magnitude apart. A protocol whose base case is broadcast rather than connect is the difference between a mesh that dies with the first flat battery and one that keeps working for hours.
Connections still exist, for rich bundles (a free-text note, the hop chain, anti-entropy digests). They are opened only by nodes that can currently afford them.
| Off | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | verType |
3b version, 3b message type, 2b reserved |
| 1 | 1 | ttlHops |
4b TTL (0–15), 4b hops travelled |
| 2 | 4 | msgId |
dedup key |
| 6 | 3 | originId |
24-bit node id |
| 9 | 4 | lat |
int32, degrees × 1e7 |
| 13 | 4 | lon |
int32, degrees × 1e7 |
| 17 | 3 | epochMin |
minutes since 2024-01-01Z — also the mesh's shared clock |
| 20 | 1 | flags |
2b severity + medical / trapped / water-rising / vulnerable / mobility |
| 21 | 1 | souls |
how many people are at this location |
| 22 | 1 | battery |
originator's battery % — see below |
| 23 | 1 | crc8 |
CRC-8/ATM over bytes 0–22 |
That battery byte is the cheapest thing in the protocol and it pays for two separate
optimisations. See docs/PROTOCOL.md for the full wire format.
How does the relay chain keep working when every device in it is running on a nearly-dead battery?
Six mechanisms. Each one is code in core/, not a slide.
Scanning costs ~100× what advertising costs, so the power ladder does not turn one dial down uniformly — it removes scanning first and protects advertising to the very end.
| Tier | Battery | Behaviour |
|---|---|---|
| BRIDGE | >60% or charging | continuous scan, GATT server up, carries the neighbourhood |
| RELAY | 30–60% | scan ~30% duty, connects on demand |
| GOSSIP | 15–30% | beacons at 1 Hz, listens 10% inside sync windows, stops paying for connections |
| FLARE | 5–15% | advertise only, ears open one second per minute |
| EMBER | <5% | beacon every 10 s, never scans |
An EMBER node is deliberately deaf. It is still shouting, and it is still findable. A dying phone should be selfish, and SafeHop encodes exactly when that switch flips.
This is the failure mode nobody demos. Two nodes each listening 5% of the time, with independent phase, overlap 0.25% of the time. They will essentially never hear each other, the network is dead, and both phones cheerfully report that everything is fine.
SafeHop derives every wake window from absolute wall-clock time, not from each node's uptime:
epoch = floor(unixMillis / 60_000)
window = [epoch × 60_000, +1_000ms)
Every node in the region therefore wakes inside the same one-second window without ever having
exchanged a scheduling message. Low tiers skip whole epochs but stay phase-aligned
(epoch % n == 0), so an EMBER node waking once every four minutes still lands exactly on a
window a BRIDGE node is listening through.
Clock quality degrades gracefully: GPS time → last NTP sync → consensus drift correction from the
epochMin field that every beacon already carries.
Bonus: one scan per 60 s epoch also keeps SafeHop inside Android's hard limit of 5 startScan
calls per 30 seconds — a throttle that silently breaks naive duty-cycling implementations.
In any neighbourhood we want few listeners and many shouters. The obvious way to arrange that is to negotiate roles — but negotiation traffic costs exactly the energy we are trying to save, and it fails precisely when the network is degraded.
Every beacon already carries the sender's battery level. That one byte is enough: every node sees
the same neighbourhood table, applies the same deterministic ranking, and independently reaches
the same answer. The top ceil(√n) elect themselves scanners for the next epoch.
Zero coordination messages. Battery is bucketed into coarse 10% bands and the tiebreak is mixed with the epoch number, so the duty rotates instead of draining the one best-charged phone. Under partition, each partition simply elects its own scanners.
The DTN literature says route copies toward higher-energy nodes. SafeHop inverts it into something that is both energy-optimal and ethically right: relay preferentially for people worse off than you. If the originator has more battery than you do and is not critical, damp hard — they can afford to keep shouting for themselves; you may not be able to.
A node always relays its own SOS. No gate applies, at any battery level.
When a gateway takes an SOS, a RECEIPT propagates back through the mesh and every node holding
that message drops it. Same for SAFE. Confirmation is not a nicety here — it is how the
mesh reclaims airtime and buffer from messages that no longer need moving.
Below 3% battery, conservation is pointless — the phone is going to die either way. SafeHop stops conserving and spends the remainder burst-advertising everything it is carrying at 250 ms intervals, so a healthier neighbour picks up custody before the lights go out.
Your phone dying is not your SOS dying.
Every radio operation is billed in estimated mAh, so the app can state plainly: "SafeHop used 1.8% of your battery in 3 hours and carried 47 messages for 12 people." An app that quietly drains a phone gets uninstalled before the disaster, and a mesh with no nodes relays nothing — so transparency is a survival feature.
The default cost constants in EnergyLedger.kt
are order-of-magnitude estimates, not measurements. See
docs/POWER.md for what has actually been measured on the demo handsets and
what has not. We would rather show a smaller verified number than a bigger invented one.
┌──────────────────────────────────────────────────────────────┐
│ APPLICATION SOS screen · Responder map · Mesh Lab │ :app
├──────────────────────────────────────────────────────────────┤
│ ENGINE MeshNode — origination, relay, snapshot │
├──────────────────────────────────────────────────────────────┤
│ ROUTING SeenSet · ForwardingPolicy · Outbox │ :core
├──────────────────────────────────────────────────────────────┤
│ POWER GOVERNOR PowerTier · Rendezvous · ScannerElection │ ← the differentiator
├──────────────────────────────────────────────────────────────┤
│ LINK (the seam) ─── opaque bytes, nothing above knows why │
├───────────────────────────┬──────────────────────────────────┤
│ AndroidLink (real BLE) │ SimLink (200 virtual nodes) │ :app / :sim
└───────────────────────────┴──────────────────────────────────┘
Three Gradle modules, one APK, zero servers, no cloud, no accounts:
| Module | Language | Depends on Android? | What it is |
|---|---|---|---|
core/ |
Kotlin/JVM | no | the protocol: codec, routing, power governor |
sim/ |
Kotlin/JVM | no | virtual mesh + battery model, drives core |
app/ |
Kotlin/Android | yes | BLE transport, foreground service, Compose UI |
Two reasons, both load-bearing:
- The scale claim is honest. The simulator that runs 200 nodes executes the same
MeshNodeclass as the radio path. It is not a separate mock that happens to agree. - Two people are not blocked on one phone.
./gradlew :core:testneeds no device and no emulator. TheLinkinterface is frozen early; after that the protocol work and the Android work proceed independently.
The kotlin-jvm plugin means "no Android in core" is enforced by the compiler, not by a code
review.
This is an active hackathon build. Honest state:
| Area | State |
|---|---|
| 24-byte beacon codec + CRC-8 | implemented, compiling |
| Seen-set dedup, outbox, carousel ordering | implemented |
| Energy-aware forwarding policy, reconsideration retry | implemented |
| Power tiers, rendezvous, scanner election, ledger, attentive mode | implemented |
MeshNode engine + Link seam |
implemented, frozen |
Unit tests (:core, :sim) |
implemented, passing |
SimLink + 200-node virtual world |
implemented |
| Android BLE transport (advertiser / scanner / GATT) | implemented, verified on hardware |
| Compose UI (SOS screen, responder map, Mesh Lab) | implemented |
| Gateway SMS/WhatsApp fallback + scannable QR fallback card | implemented |
| Simulator-measured energy reclaim from delivery confirmation | 55.54%, 20-node flood scenario |
| Per-radio-op energy costs (mAh) feeding the ledger | estimated, not yet measured on real silicon |
Web console, cloud backend, iOS, a Wi-Fi Direct tier, encryption beyond bundle signatures,
accounts, real map tiles. Each was cut to keep one APK finishable and demoable. See
docs/PRD.md for the reasoning.
Beacons are unsigned — there is no room for a 64-byte signature in 24 bytes. Rich bundles
carry Ed25519; beacons do not. The UI must therefore distinguish an unverified beacon from a
signed bundle, and rate-limit per originator. See docs/THREAT-MODEL.md.
Don't want to build from source? Grab the signed APK from safehop.strawhats.co.in and sideload it directly.
Requires JDK 17, Android SDK platform 37, build-tools 36.0.0.
./gradlew :core:test./gradlew :app:assembleDebugVersions are pinned in gradle/libs.versions.toml and were resolved
against Google Maven on 2026-08-22: AGP 9.3.0 · Gradle 9.5.0 · Kotlin 2.3.21 · compileSdk 37 ·
minSdk 26 · Compose BOM 2026.08.00. Do not bump them during the build window.
minSdk 26 is forced by BluetoothAdapter.isLeExtendedAdvertisingSupported().
| Document | What it covers |
|---|---|
docs/PRD.md |
product requirements, scope, non-goals, success criteria |
docs/ARCHITECTURE.md |
module boundaries, the seam, data flow |
docs/PROTOCOL.md |
wire format, message types, dedup, TTL |
docs/POWER.md |
the battery answer in full, with measurement plan |
docs/DEMO.md |
the five-minute demo script |
docs/THREAT-MODEL.md |
what SafeHop does and does not defend against |
docs/tasks/ |
Android build tasks, B1–B8, with acceptance criteria |
docs/adr/ |
architecture decision records |
SafeHop is not the first BLE mesh messenger and does not claim to be. Bridgefy, Briar and bitchat all relay messages over Bluetooth; bitchat in particular validates the dual-role GATT + controlled-flood + TTL-7 approach on Android. Meshtastic does the same over LoRa.
What is different here is the objective function. Those projects optimise for message delivery and treat battery as a constraint to be respected. SafeHop treats residual battery as the scarce resource being allocated — it is an input to routing, to scheduling, to role election, and to what the app tells you about itself.
MIT — see LICENSE.