Created and maintained by @Gautam-aman.
Open-source contributions are welcome! 🤝
Offline-first emergency messaging over a peer-to-peer device mesh.
ResQNet is a mesh networking layer for phones. It carries emergency messages between nearby devices when the infrastructure they normally depend on is gone — a cell tower without power, a flooded exchange, a cut fibre.
The radios are already in the phones. So are the batteries, and so are the people who need to reach each other. ResQNet is the layer that makes those radios useful to one another.
A handset composes an SOS. The message is handed to whichever devices are connected, they hand it onward, and it keeps moving until it reaches the device that owns the responder — or until its lifetime expires. No server is involved at any point on that path. The coordination gateway is optional, runs only when there is connectivity, and is never on the emergency path.
This is experimental research software. It is not a substitute for official emergency services and must not be relied upon as one.
Concept visualization — not actual device-test footage. This is an illustrative animation produced for the project. It is not a screen recording of the Android app and not a measurement of real radio behaviour.
Watch the full 38-second explainer (MP4, 1.2 MB) — also a concept visualization, with the same disclaimer applying.
A step-by-step demonstration script with exact commands and failure injection is in docs/demo.md.
Disasters do not fail gently. The first thing to go is usually the thing everything else depends on.
| What fails | What still works |
|---|---|
| Cell towers lose power | Bluetooth and Wi-Fi radios stay on |
| Backhaul is cut | Handsets are still in each other's range |
| The internet is unreachable | People are still holding the phones |
| An account system is unreachable | A key pair is already on the device |
Infrastructure-scale networks need infrastructure. A mesh does not. The constraint is honest and small: if a message cannot reach a recipient within the hop budget before its lifetime expires, it is reported as expired rather than as delivered.
- Works with no infrastructure. No server, account, or SIM is required on the emergency path.
- Store and forward. A relay persists a message before acknowledging it, and the queue survives the process dying.
- Cryptographic identity. Identity is a key, not an account. Every envelope is signed; there is no trust-on-first-use.
- Offline triage. A keyword classifier runs on the handset with no network, so a category and severity exist before anything is transmitted.
- Explicit delivery states.
SENT_TO_PEERandACKNOWLEDGEDare never collapsed into one another. - Honest failure. A message that could not be delivered is reported as expired or failed, never as delivered.
sequenceDiagram
participant A as Phone A - Sender
participant B as Phone B - Relay
participant C as Phone C - Responder
Note over A: No internet. Compose SOS offline.
A->>B: Signed SOS envelope - hop 1
Note over B: Persist to SQLite and forward
B->>C: Signed SOS envelope - hop 2
Note over C: Verify origin signature
C-->>B: Application-level ACK
B-->>A: Forward ACK to sender
Note over A,C: Peer transfer alone does not prove final delivery
Four rules govern forwarding:
- Split horizon. An envelope is never handed back to the peer it arrived from, which removes the most common loop.
- Hop budget. Every relay decrements a bounded budget. A flood wider than the budget cannot cross it, however long the sender waits.
- Duplicate suppression. Keyed on message id and bounded on three axes — entries, bytes, and age — so a long-running handset cannot grow without limit.
- Signature verification. Each hop verifies against a key the owner has
confirmed. A device you have never met is
UNVERIFIED, not trusted.
SENT_TO_PEER and ACKNOWLEDGED are different states and are never collapsed.
A successful Nearby Connections payload transfer proves one radio hop succeeded.
It does not prove a person received anything. The UI renders SENT_TO_PEER as
"Handed to a nearby device", never as delivered, because a responder
deciding whether to hold a position needs to know the difference.
graph TB
subgraph Mesh["Device mesh — no infrastructure required"]
A["<b>Handset A</b><br/>UI · MeshService<br/>MeshRouter · SQLite queue"]
B["<b>Handset B</b><br/>UI · MeshService<br/>MeshRouter · SQLite queue"]
C["<b>Handset C</b><br/>UI · MeshService<br/>MeshRouter · SQLite queue"]
A <-->|"Bluetooth / Wi-Fi Direct<br/>Nearby Connections"| B
B <-->|"Bluetooth / Wi-Fi Direct"| C
end
subgraph Optional["Optional — only when connectivity exists"]
G["<b>Coordination gateway</b><br/>Spring Boot · PostgreSQL<br/>incidents · audit trail"]
W["<b>Command centre</b><br/>React · WebSocket"]
end
A -.->|"metadata only, never bodies"| G
C -.->|"metadata only"| G
G <--> W
style Mesh fill:#0f2a24,stroke:#1f7a6d,color:#e6f7f3
style Optional fill:#1a1a2e,stroke:#3d3d6b,color:#e0e0f0
The gateway and command centre sit outside the mesh on purpose. A handset may synchronise with the gateway when it has connectivity and a responder console reads from it, but nothing on the emergency path depends on it existing. Synchronisation sends delivery metadata — ids, states, timestamps, attempt counts. Message bodies are never sent to it.
The full design, including the reasoning behind each trade-off, is in docs/architecture/README.md.
apps/android — Java, Android SDK, Nearby Connections.
- Nearby Connections via the current
ConnectionsClientAPI, strategyP2P_CLUSTER. - Foreground service so the mesh survives the screen turning off.
- SQLite-backed durable queue, so a queued message survives the process dying.
- Compiles mesh-core from source. A mesh-core change that breaks Android is
caught by
apps/android/app/build.gradlerather than at release time. - User-controlled mesh participation. Mesh enablement is an explicit choice, not a default.
Minimum supported version is Android 8.0 (API 26). Debug builds carry a
.debug application-id suffix, so a debug and a release build install side by
side without replacing each other.
Background restrictions matter. Doze mode, battery optimisation and manufacturer-specific task killers can suspend the foreground service. See Known limitations.
apps/web — React 18, TypeScript, Vite, Zustand.
- A landing page that makes one claim and supports it.
- A responder command centre: queue, incident detail, live counters, topology.
- A live event stream over WebSocket, with polling fallback.
The client distinguishes the gateway is unreachable from there are no incidents. Conflating them would let a responder act on a dashboard that is quietly reporting an empty queue because it could not reach the server.
Generated simulator runs are labelled as simulated at every layer — the dashboard, the API, and a database column. There is no path by which generated data is presented as field telemetry.
| Layer | Technology |
|---|---|
| Mesh core | Java 21, zero runtime dependencies, runs unchanged on Android |
| Simulator | Java 21, discrete-event, deterministic from a seed |
| Gateway | Java 21, Spring Boot 3.5, PostgreSQL, Flyway, JPA/Hibernate |
| Android | Java, Android SDK, Nearby Connections P2P_CLUSTER, SQLite, Robolectric |
| Web | React 18, TypeScript, Vite, Zustand, WebSocket with polling fallback |
| CI | GitHub Actions: Java, backend (H2 + PostgreSQL), Android, instrumented Android, web |
Requires JDK 21, Maven 3.9+, Node.js 24 LTS for the web app.
git clone https://github.com/Gautam-aman/ResQNet.git
cd ResQNet
# Java modules: mesh core, simulator, gateway
mvn -q package
# Web
cd apps/web && npm ci && npm run buildjava -jar services/backend/target/backend-*.jar \
--spring.profiles.active=devRequires RESQNET_JWT_SECRET (32+ characters) and, to let any device
synchronise, RESQNET_GATEWAY_TOKEN. Without them the application refuses to
start, which is deliberate.
The dev profile seeds two accounts with the password resqnet-dev:
responder and admin. They exist only under dev.
To use PostgreSQL rather than H2:
RESQNET_DB_URL=jdbc:postgresql://localhost:5432/resqnet \
RESQNET_DB_USER=resqnet RESQNET_DB_PASSWORD=resqnet \
java -jar services/backend/target/backend-*.jar \
--spring.profiles.active=devThe backend jar has no bundled H2 driver, because H2 is test scope. Pointing it at H2 fails at startup by design; it targets PostgreSQL.
cd apps/web && npm run dev # http://localhost:5173The command centre is at /command. The dev server proxies /api and /ws
to localhost:8080.
cd apps/android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkRelease signing reads four properties from your own Gradle home and is
deliberately unset in the repository. With any of the four missing,
./gradlew assembleRelease produces an unsigned APK — it does not fall back
to the debug key, because a debug-signed APK is installable and looks exactly
like a release until somebody tries to ship it.
# ~/.gradle/gradle.properties — never the project file, never committed
RESQNET_KEYSTORE_FILE=/absolute/path/to/resqnet-release.jks
RESQNET_KEYSTORE_PASSWORD=...
RESQNET_KEY_ALIAS=resqnet
RESQNET_KEY_PASSWORD=...The first publicly downloadable Android build ships with v0.2.0-beta, published as a pre-release.
| Package | dev.resqnet.android |
| Minimum Android | 8.0 (API 26) |
| Signing | Release keystore, verified with apksigner |
| Checksums | SHA256SUMS alongside every release asset |
Installation is covered in docs/release/0.2.0-beta/INSTALLATION.md. Verify the SHA-256 checksum before installing, and only install APKs obtained from this repository's releases page.
Installing APKs outside the Play Store should only ever be done from a source you trust. This build is experimental and is offered for evaluation.
packages/mesh-simulator is a discrete-event model of the same routing
code. It is not a mock: it runs the real MeshRouter with a modelled radio.
- Line, ring, star, mesh, two-cluster and random topologies.
- Link loss, churn and partition modelling.
- Reproducible from a seed. See Reproducibility.
- Publishes reports straight into a running gateway.
CP=packages/mesh-simulator/target/classes:packages/mesh-core/target/classes
java -cp $CP dev.resqnet.mesh.simulator.SimulatorCli \
--topology mesh --nodes 12 --messages 40 --seed 7 --rounds 60 \
--out /tmp/report.jsonUseful flags: --drop 0.2 to lose 20% of frames, --churn 6 to cut and restore
a link every six rounds, --partitioned to start with the busiest relay offline.
RESQNET_GATEWAY_TOKEN=dev-device-token \
java -cp $CP dev.resqnet.mesh.simulator.SimulatorCli \
--topology mesh --nodes 14 --messages 50 --seed 11 --quiet \
--gateway http://localhost:8080/api/v1/simulationsIngestion authenticates as a device, using the shared token. This is deliberate: a simulator is a generator, not a handset, and giving it a human session would imply an accountability model that does not exist.
java -cp $CP dev.resqnet.mesh.simulator.demo.DemoScenarioSeven stages over the real MeshRouter with a modelled radio: composition with
no network, two-hop delivery, a relay vanishing mid-flight, recovery, gateway
sync without content, end-to-end confirmation, and the responder view. It exits
non-zero if any stage misbehaves, and three tests assert on that result.
The same demonstration runs from a release artifact with no infrastructure:
java -cp dist/resqnet-simulator-v*.jar \
dev.resqnet.mesh.simulator.demo.DemoScenario| Suite | Tests | Command |
|---|---|---|
| mesh-core | 154 | mvn -pl packages/mesh-core test |
| simulator | 51 | mvn -pl packages/mesh-simulator -am test |
| backend (H2) | 60 | mvn -pl services/backend test |
| backend (PostgreSQL) | 7 | runs in CI against postgres:16-alpine |
| Android unit + Robolectric | 56 | cd apps/android && ./gradlew testDebugUnitTest |
| Android instrumented | 33 | cd apps/android && ./gradlew connectedDebugAndroidTest |
| Web | 46 | cd apps/web && npm run test |
Seven simulator tests are tagged slow because they run whole simulated meshes
rather than methods. They are excluded from an ordinary build so a push stays
quick, and CI asks for them explicitly with -DexcludedTestGroups=, so nothing
is silently skipped.
Instrumented tests run in CI at 320×640 @ 160 dpi, and have also been run locally at 1080×2400 @ 420 dpi. Multi-emulator testing has not been performed.
The PostgreSQL integration tests are not run by default, because they need a live server:
mvn -pl services/backend -am test \
-Dspring.profiles.active=postgres \
-Dtest=PostgresMigrationIT \
-Dsurefire.failIfNoSpecifiedTests=falsewith TEST_PG_URL, TEST_PG_USER and TEST_PG_PASSWORD pointing at a live
instance.
Delivery figures are computed over directed messages only. A broadcast has no destination, so nothing can acknowledge it, and counting broadcasts in the denominator capped the reported rate below 100% however well the mesh performed. Both counts are reported separately.
What limits delivery is the graph, not the router. TopologyDiagnostics
measures the shapes the simulator builds. MESH is a circulant band of diameter
n/4, so at 500 nodes only a quarter of node pairs are inside a 32-hop budget.
Across hop budgets at 100 nodes the router reaches 98–100% of what the graph
permits, and at 500 nodes the measured 21.6% sits directly under the 25.7%
structural ceiling. The full argument, including seven causes that were tested
and ruled out, is in docs/routing-analysis.md.
Mesh topology, one seed, same messages at every size:
| Nodes | 4 hops | 8 hops | 16 hops | 32 hops |
|---|---|---|---|---|
| 10 | 90.0% | 90.0% | 90.0% | 90.0% |
| 50 | 15.0% | 50.0% | 90.0% | 90.0% |
| 100 | 24.0% | 44.0% | 76.0% | 88.0% |
| 500 | 2.4% | 4.0% | 10.4% | 21.6% |
The headline finding is that hop budget, not mesh size, decides whether a message crosses. A flood travels one hop per relay, so a mesh wider than the hop budget cannot be crossed no matter how long the sender waits.
Full numbers, with the host they were measured on, are in docs/benchmarks/results.md.
These are simulation results, not field measurements.
This distinction is the most valuable thing in the repository. It is easy to erode, so it is stated explicitly:
| Evidence class | Status |
|---|---|
| Automated unit and JVM tests | ✅ Passing, run in CI |
| Robolectric tests | ✅ Passing |
| Emulator instrumentation | ✅ Passing, at two geometries |
| Simulator multi-hop demonstration | ✅ Passing, deterministic from a seed |
| Backend against real PostgreSQL | ✅ Passing in CI |
| Physical devices | |
| Three-phone multi-hop | ❌ Verified |
Physical-device testing. The developer reports the Android application
working on two physical phones. That is a user report, not independently
verified evidence, and it is not evidence of multi-hop routing: with two phones
in range of each other there is no third device to relay through, so
P2P_CLUSTER connects them directly and no hop is taken.
Not verified: end-to-end offline delivery between three or more physical devices, cryptographic acknowledgements over real radios, physical radio range, and battery behaviour under continuous mesh participation.
The procedure for running a proper hardware test is in apps/android/DEVICE_TESTING.md.
SimulationReport.volatileJsonFields() names exactly three things that cannot
be byte-stable across runs of the same seed, and everything else is
reproducible: the wall-clock and throughput measurements, and the freshly
generated device ids and key fingerprints.
Every routing decision, counter, latency and hop distribution is a function of the seed. This is enforced by a test that forks the CLI in a separate JVM, because the failure mode it guards against is invisible in-process: the JDK randomises its immutable collection iteration order once per JVM, so two runs in one process agree with each other and disagree with every other process.
python3 scripts/compare_reports.py /tmp/a.json /tmp/b.jsonThreat model in docs/security/threat-model.md.
- No trust on first use. Identity is a key. A device you have never met is
UNVERIFIED, not trusted. Keys are exchanged explicitly, out of band, and the two people compare short fingerprints in person. - Signatures. Envelopes are signed with ECDSA over P-256
(
SHA256withECDSA). A device's id is derived from its public key, so there is no account to lose and no server that can impersonate a handset. - Devices cannot act. The gateway token authorises reporting only. It can never open, assign or close an incident.
- People and devices use different credentials. A device cannot obtain a responder session, and a responder token cannot push device data.
- Message bodies never leave the mesh. Synchronisation sends delivery metadata only.
- The audit trail does not overstate authority. A gateway-synchronised
incident is recorded as
DEVICE, because no human triaged it.
Known limitations are listed in the threat model rather than left implicit. The most important: the shared gateway token is a bearer secret, so it is revocable by rotation but not attributable, and every device holding it shares one identity.
Payloads are signed but not encrypted. Envelope signatures guarantee authenticity and integrity; they do not provide confidentiality. Any device in radio range that receives a frame can read its contents.
Stated plainly so they are not discovered by a reviewer.
Validation gaps
- Three-device multi-hop routing has never been verified on physical hardware. Several defects found by auditing the app would each have prevented a three-phone demonstration on their own. They are fixed, but nothing yet proves the radio path works end to end.
- Real-world range and battery life are unknown. The benchmarks measure the routing engine against a modelled radio. Nothing here is a claim about how far a message travels through a collapsed building.
- Multi-emulator testing has not been performed.
- PostgreSQL under concurrency is untested. Migrations and lifecycle are verified; write contention and replication are not.
Platform behaviour
- Multi-hop cannot be observed when phones are in range of each other. This
is not a defect, but it looks like one.
P2P_CLUSTERis an M-to-N strategy: every device within radio range connects to every other, so with three phones side by side, A to C is one hop and no relay is involved. A demonstration has to put the phones out of range of each other for the relay to exist at all, which is whyDEVICE_TESTING.mdinsists on a closed door and on confirming C does not appear in A's peer list before sending. - Android background restrictions may suspend the mesh. Doze mode, battery optimisation and manufacturer task killers can stop the foreground service.
- No
P2P_CLUSTERauthentication token is used. Google's API makes it optional. Trust is enforced one layer up, by envelope signature verification against a confirmed key — a stronger property, but it also means an unauthenticated peer can occupy a connection slot.
Protocol and product
- No payload encryption. See Security and privacy.
- The peer-identity rebinding path is not proven by a challenge-response. Rebinding a known identity to a new radio endpoint relies on the claimant presenting a public key already on file. A public key is public, so a device that has met the victim can rebind the victim's identity to its own endpoint and black-hole or read traffic addressed to them. It cannot forge frames as the victim. Closing this properly requires a link-bound challenge-response, which is not implemented.
- Assignment and manual incident creation have working endpoints but no UI.
Known-unfixed operational notes
- The shared gateway token is a single bearer secret for all devices.
Ordered by what would most increase confidence, not by what would look best.
- Physical multi-hop validation. Run
DEVICE_TESTING.mdwith three or more devices and record the result. This is the single largest gap. - Challenge-response identity binding. Close the rebinding limitation described above with a link-bound nonce the claimant must sign.
- Payload encryption. Add confidentiality on top of the existing authenticity.
- Command-centre controls for incident assignment and manual creation.
- Concurrent-load testing of the gateway against PostgreSQL.
Issues and pull requests are welcome, and responsible bug reports are especially valuable — particularly about routing correctness, message integrity, or security.
Start with CONTRIBUTING.md for setup, test commands, evidence expectations, and pull request guidance. Participation is governed by CODE_OF_CONDUCT.md. Report vulnerabilities privately as described in SECURITY.md, never in a public issue.
Read docs/security/threat-model.md before proposing a change to the trust or transport model.
When you report a finding, say honestly whether you reproduced it, read it, or inferred it. Several defects here were found by reading the code rather than by running it, and the difference between a reproduced and an inferred report matters a great deal to whoever triages it.
If you have tested the Android app on real hardware, that result is worth more than anything else you could send. The procedure is in apps/android/DEVICE_TESTING.md.
Please keep the "what is verified / what is simulated / what is untested" distinction intact in any change. It is the most valuable thing in this repository, and it is easy to erode by accident.
MIT. See LICENSE.
