No internet. No cell service. No infrastructure required. One device with a connection becomes the seed. Every phone that receives an alert automatically becomes a Relay — spreading it further through the crowd over Bluetooth.
- How It Works
- Architecture
- BLE Protocol
- Data Model
- Echo Path (Hop Counting)
- App Features
- Native Offline Translation
- Settings & NWS Filtering
- Project Structure
- Dependencies
- Build & Run
- Relay Node (beacon_cli / Pi Sender)
- Platform Notes
- Debugging BLE
- Demo Walkthrough
┌─────────────────┐ BLE GATT ┌──────────────┐ BLE GATT ┌──────────────┐
│ Relay Node │ ──────────────► │ Echo A │ ──────────────► │ Echo B │
│ (Pi / laptop / │ alert JSON │ 1 echo │ re-broadcasts │ 2 echoes │
│ phone w/ net) │ in chunks │ (no Wi-Fi) │ alert │ (no Wi-Fi) │
│ │ │ │ │ │
│ NWS API fetch │ │ Stores alert │ │ Stores alert │
│ + GATT server │ │ Re-advertises│ │ Re-advertises│
└─────────────────┘ └──────────────┘ └──────────────┘
▲
│ HTTPS
┌───────────────┐
│ NWS GeoJSON │
│ alerts/active│
└───────────────┘
- A Relay node (Pi, laptop running
beacon_cli, or a phone with internet) fetches live alerts from the National Weather Service API. - It advertises the alert over BLE using a custom GATT server, chunking the JSON payload across multiple characteristic reads.
- Any nearby Echo (phone running the app) scans for the service UUID, connects, downloads and reassembles the chunks, and saves the alert to local SQLite.
- That Echo then re-advertises the same alert — becoming a Relay node in the gossip mesh.
- Each relay increments the echo counter, so recipients see exactly how many devices the alert traveled through to reach them.
- A deduplication check (
alertIdHashin BLE advertisement + SQLite lookup) prevents infinite relay loops.
Echo uses a unified mesh node model. Every device simultaneously acts as both broadcaster and scanner — there are no fixed gateway or receiver roles.
| Context | Runs |
|---|---|
| Main Flutter isolate | UI, GattServer (MethodChannel → native BLE), foreground BLE scan, NWS fetch |
Background isolate (flutter_background_service) |
Autonomous 5-minute mesh loop: scan → deduplicate → download → persist → notify main |
Critical constraint:
GattServeruses aMethodChannelregistered only in the main Flutter engine (MainActivity/AppDelegate). Calling it from the background isolate throwsMissingPluginException. All GATT server operations must stay in the main isolate.
Background ──► 'serviceStarted' ──► Main (activates MESH ACTIVE badge + pulse)
Background ──► 'meshAlert' ──► Main (calls GattServer.restart, reloads DB)
Main ──► 'notifyAlert' ──► Background (keeps background DB in sync after NWS fetch)
scanForMesh(15 seconds)
└─ for each Relay beacon found:
└─ alertIdHash already in DB? ──yes──► skip (loop prevention)
──no───► GattClient.downloadAlert()
└─ echoCount + 1
└─ persist to SQLite
└─ emit 'meshAlert' IPC → main isolate
└─ break (one new alert per cycle)
| Role | UUID |
|---|---|
| Service | 0000BCBC-0000-1000-8000-00805F9B34FB |
| Alert characteristic (READ) | 0000BCB1-0000-1000-8000-00805F9B34FB |
| Control characteristic (WRITE) | 0000BCB2-0000-1000-8000-00805F9B34FB |
| Manufacturer ID | 0x1234 |
Packed into the 31-byte BLE advertisement alongside the service UUID:
[severity : 1 byte ] 0=Extreme, 1=Severe, 2=Moderate, 3=Minor, 4=Unknown
[alertIdHash: 4 bytes] first 4 bytes of SHA-1(headline + expires)
[fetchedAt : 4 bytes] Unix epoch, big-endian
Receivers filter on the service UUID or by BLE device name "BeConnect" (iOS fallback — CoreBluetooth sometimes omits service UUIDs from advertisement data). Severity and alertIdHash are parsed from manufacturer data to decide whether to connect — without touching GATT.
Receiver GATT Server (Relay)
│ │
│── connect() + requestMtu(512) ─────────────►│
│◄─── mtuChanged(512) ────────────────────────│
│── discoverServices() ──────────────────────►│
│ │
│ repeat for i = 0 .. totalChunks - 1: │
│── write(controlChar, [i >> 8, i & 0xFF]) ──►│ ← "give me chunk i"
│── read(alertChar) ─────────────────────────►│
│◄─── [i:2 BE][total:2 BE][payload:≤508 B] ───│
│ │
│── disconnect() ────────────────────────────►│
│ │
reassemble → JSON → AlertPacket
Frame format:
┌──────────────┬────────────────┬──────────────────────────┐
│ chunkIndex │ totalChunks │ payload │
│ 2 bytes BE │ 2 bytes BE │ up to 508 bytes │
└──────────────┴────────────────┴──────────────────────────┘
- Native chunk size: 508 bytes (512 MTU − 4-byte header)
- GATT error 133 on Android is normal on first connect —
GattClientautomatically retries once after 600ms
@JsonSerializable()
class AlertPacket {
final String alertId; // First 8 hex chars of SHA-1(headline + expires)
final String severity; // "Extreme" | "Severe" | "Moderate" | "Minor" | "Unknown"
final String headline;
final int expires; // Unix epoch seconds
final String instructions;
final String sourceUrl;
final bool verified; // true = fetched directly from NWS
final int fetchedAt; // Unix epoch seconds (local receipt time)
final int? sentAt; // Unix epoch seconds (NWS-issued time; null for BLE-relayed)
final bool pinned; // User-pinned; survives the 20-alert prune
final int hopCount; // 0 = origin; incremented +1 per BLE relay hop
}CREATE TABLE alerts (
alertId TEXT PRIMARY KEY,
severity TEXT NOT NULL,
headline TEXT NOT NULL,
expires INTEGER NOT NULL,
instructions TEXT NOT NULL,
sourceUrl TEXT NOT NULL,
verified INTEGER NOT NULL,
fetchedAt INTEGER NOT NULL,
sentAt INTEGER, -- nullable; NWS-issued time
pinned INTEGER NOT NULL DEFAULT 0,
hopCount INTEGER NOT NULL DEFAULT 0
);- Prunes to the 20 most recent alerts automatically after every insert.
- Pinned alerts are excluded from pruning.
- Ordered by:
pinned DESC,fetchedAt DESC. - Migration history: v1 → v2 added
pinned; v2 → v3 addedhopCount; v3 → v4 addedsentAt.
| Scenario | hopCount stored |
|---|---|
| NWS fetch or demo loaded directly on this device | 0 |
| Relay → Echo A (direct receive) | 1 |
| Relay → Echo A → Echo B | 2 |
| Relay → A → B → C | 3 |
The alert detail screen renders this as a visual node chain:
🟢 ───────── ⚪ ───────── 📱
origin relay you
(Relay/NWS) (1 Echo) (2 echoes)
Nodes: green tower = origin Relay, white bluetooth circles = relaying Echo devices, blue phone = this device.
Plain-English examples shown under the chain:
hopCount: 0→ "Received directly from NWS (no echoes)"hopCount: 1→ "Relayed directly from a Relay node (1 echo)"hopCount: 3→ "Echoed through 2 devices before reaching you (3 echoes)"
| Feature | Description |
|---|---|
| Auto-scan on launch | 15-second foreground BLE scan runs immediately after permissions are granted |
| MESH ACTIVE chip | Green with breathing glow when background mesh is alive; turns red "BT OFF" when Bluetooth is disabled |
| Settings gear | Opens the Settings screen (state filter, NWS fetch, demo load) |
| Manual re-scan FAB | BLE search FAB in the bottom corner; scales in with elastic-out animation |
| Staggered alert cards | Cards slide up and fade in with 50ms stagger per card |
| Severity glow | Extreme and Severe cards have a pulsing colored outer glow |
| Swipe-left to reveal | Swipe any card left to reveal circular Pin and Delete action buttons |
| One card at a time | Opening a swipe on one card automatically closes all others |
| Press scale | Cards scale to 0.97× on press for tactile feedback |
| Alert tags | Green NWS badge for verified NWS alerts; dim DEMO pill for demo alerts; no tag for BLE-relayed alerts |
| NWS sent time | Alert cards show the NWS-issued timestamp (sentAt) when available; falls back to received-ago age |
| Feature | Description |
|---|---|
| Severity banner | Colored glass panel with icon and severity label; Extreme/Severe have outer glow |
| Language picker | Glass row tapping opens a glass bottom sheet — choose from 23 languages to translate the alert instructions |
| Offline translation | Native on-device translation (iOS 18+ Translation framework / Android ML Kit); no internet required after model download |
| Read Aloud | Full-width button + AppBar icon; speaks instructions in the selected language via native TTS |
| TTS language | Automatically set to match the chosen translation language (e.g. Spanish → es-ES, Hindi → hi-IN) |
| Auto-stop TTS | Stops automatically when navigating back |
| Echo Path | Visual node chain showing origin → relay echoes → this device |
| Issued time | "Issued" row shows the NWS-issued sentAt time when available |
| Expiry time | Formatted local date/time |
| Metadata | Source URL, Alert ID, delivery path |
- Dark glassmorphism — dark navy base (
#0D0F1A), two animated warm gradient blobs (17s and 19s drift cycles) behind all content,BackdropFilterblur on fixed surfaces only (AppBar, Settings, modals, language picker). - Severity color scale — 5 levels from soft yellow (Minor) through amber → orange → red → deep crimson (Extreme). Every card, badge, border, and glow is dynamically colored by severity.
- Glass swipe actions — revealed Pin/Delete buttons are circular, glass-style, fully rounded on all edges.
- Page transitions — fade + 4% slide-up applied to both iOS and Android, replacing the default platform slide.
- No blur inside list items —
BackdropFilteris deliberately excluded fromSliverListchildren to prevent jank.
Echo supports on-device translation of alert instructions into 23 languages without any internet connection after initial model download.
| Language | Code | TTS Locale |
|---|---|---|
| Arabic | ar |
ar-SA |
| Chinese (Simplified) | zh |
zh-CN |
| Dutch | nl |
nl-NL |
| French | fr |
fr-FR |
| German | de |
de-DE |
| Greek | el |
el-GR |
| Hebrew | he |
he-IL |
| Hindi | hi |
hi-IN |
| Indonesian | id |
id-ID |
| Italian | it |
it-IT |
| Japanese | ja |
ja-JP |
| Korean | ko |
ko-KR |
| Norwegian | nb |
nb-NO |
| Polish | pl |
pl-PL |
| Portuguese | pt |
pt-BR |
| Russian | ru |
ru-RU |
| Spanish | es |
es-ES |
| Swedish | sv |
sv-SE |
| Thai | th |
th-TH |
| Turkish | tr |
tr-TR |
| Ukrainian | uk |
uk-UA |
| Vietnamese | vi |
vi-VN |
| Platform | Framework | Requirement |
|---|---|---|
| iOS | Apple Translation framework | iOS 18.0+ |
| Android | Google ML Kit Translation | Any Android; models download on first use |
- On iOS < 18.0, the language dropdown is shown but selecting a language displays a snackbar: "Translation requires iOS 18.0+"
- The language picker only shows languages with a downloaded model on iOS (checked via
LanguageAvailability). On Android, all 22 languages are always shown (ML Kit downloads the model on first use). - Read Aloud automatically uses the TTS locale matching the selected translation language.
Tap the gear icon in the top-right corner to open Settings.
| Button | Action |
|---|---|
| Download NWS Alerts | Fetches up to 5 active alerts from api.weather.gov filtered by selected states |
| Load Demo Alerts | Injects hardcoded sample alerts (Extreme + Severe) for offline testing |
Select one or more US states to filter NWS alerts geographically. The app appends &area=CA,TX,... to the NWS API request. When no states are selected, alerts for all states are fetched.
- Selected states are persisted to
SharedPreferencesand survive app restarts. - Tap Clear to deselect all states and revert to fetching all states.
- Changes take effect on the next NWS fetch.
lib/
├── main.dart # App entry, ThemeData, page transitions
├── ble_constants.dart # Service UUIDs, manufacturer ID, severity byte mapping
├── ble/
│ ├── ble_advertiser.dart # Thin delegate → GattServer
│ ├── ble_scanner.dart # BLE scan + filter by serviceUuid + device name fallback
│ ├── gatt_client.dart # Connect, chunked download, hopCount increment
│ ├── gatt_server.dart # MethodChannel bridge: start / stop / restart
│ └── chunk_utils.dart # Frame encode / decode / reassemble
├── network/
│ ├── alert_fetcher.dart # NWS GeoJSON HTTP client; supports state area filter
│ └── alert_parser.dart # GeoJSON features → AlertPacket (parses sentAt)
├── data/
│ ├── alert_packet.dart # Data model + json_serializable; includes sentAt: int?
│ ├── alert_packet.g.dart # ⚠️ Generated — do not edit
│ ├── alert_database.dart # SQLite singleton, schema v4, auto-migration
│ └── alert_dao.dart # insert, hasAlert, fetchAll, setPinned, delete, prune
├── service/
│ └── gateway_background_service.dart # Background isolate, mesh loop, IPC events
├── services/
│ └── translation_service.dart # MethodChannel wrapper for native translation
├── utils/
│ └── permissions.dart # requestBlePermissions() — iOS + Android
├── ui/
│ ├── home_screen.dart # Main screen: swipe cards, mesh chip, scan FAB
│ ├── settings_screen.dart # State multi-select + NWS/Demo action buttons
│ ├── theme/
│ │ └── severity_colors.dart # main(), tint(), border(), hasGlow() helpers
│ ├── widgets/
│ │ ├── glass_scaffold.dart # Dark bg + animated drifting blobs
│ │ └── glass_container.dart # Reusable blurred glass panel widget
│ └── receiver/
│ └── alert_detail_screen.dart # Detail: translation picker, TTS, echo chain, metadata
└── demo/
└── demo_alerts.dart # Hardcoded fallback AlertPackets (verified: false)
android/app/src/main/kotlin/.../
└── MainActivity.kt # BluetoothLeAdvertiser + BluetoothGattServer
# 508-byte chunks, per-device chunk index tracking
# ML Kit Translation channel handler
ios/Runner/
└── AppDelegate.swift # CBPeripheralManager, same 508-byte chunks
# Apple Translation channel handler (iOS 18.0+)
assets/
├── icon/
│ └── app_icon.png # 1024×1024 source — flutter_launcher_icons generates all sizes
└── sample/
├── home.png # Home screen screenshot
├── Alert.png # Alert detail screenshot
└── Alert_Hindi.png # Alert detail with Hindi translation
beacon_cli/ # Cross-platform BLE Relay node (macOS + Windows)
pi_sender/ # Raspberry Pi Relay node (Linux / BlueZ)
| Package | Version | Purpose |
|---|---|---|
flutter_blue_plus |
^1.32.0 | BLE central (scan) + peripheral (advertise) on Android & iOS |
http |
^1.2.0 | NWS GeoJSON API requests |
sqflite |
^2.3.0 | Local SQLite persistence |
path_provider |
^2.1.0 | Database file path resolution |
json_annotation |
^4.9.0 | @JsonSerializable annotations |
flutter_background_service |
^5.0.5 | Background isolate + foreground notification (Android) |
permission_handler |
^11.3.0 | Runtime BLE + location + notification permissions |
crypto |
^3.0.3 | SHA-1 for alertId generation |
flutter_tts |
^4.0.2 | Native iOS AVSpeechSynthesizer / Android TextToSpeech |
shared_preferences |
^2.3.0 | Persist selected NWS states across restarts |
| Package | Purpose |
|---|---|
build_runner |
Code generation runner |
json_serializable |
Generates alert_packet.g.dart from annotations |
flutter_launcher_icons |
Generates all required icon sizes from a single 1024×1024 source |
| Framework | Purpose |
|---|---|
CoreBluetooth |
BLE GATT peripheral (advertising) |
Translation (iOS 18.0+) |
On-device text translation via Apple Intelligence |
SwiftUI |
Required to host TranslationSession in a zero-size view |
| Library | Purpose |
|---|---|
BluetoothLeAdvertiser |
BLE GATT peripheral |
com.google.mlkit:translate |
On-device ML Kit translation |
| Package | Purpose |
|---|---|
bless |
Cross-platform BLE GATT peripheral (macOS + Windows) — beacon_cli |
dbus-next |
BlueZ D-Bus GATT server (Raspberry Pi / Linux) — pi_sender |
pytest (dev) |
Unit test runner |
- Flutter SDK ≥ 3.6.2 on your
PATH - Dart SDK ≥ 3.6.2 (bundled with Flutter)
- For iOS builds: macOS + Xcode 15+
- For Android builds: Android Studio + SDK Platform 34
# Install Dart/Flutter dependencies
flutter pub get
# Regenerate JSON serialization code
# (Only needed after editing alert_packet.dart)
dart run build_runner build --delete-conflicting-outputs
# Regenerate app icons
# (Only needed after replacing assets/icon/app_icon.png)
dart run flutter_launcher_icons# Run on connected device (debug)
flutter run
# Lint — must report 0 issues
flutter analyze
# Unit tests
flutter test
# Watch mode for JSON codegen during development
dart run build_runner watch --delete-conflicting-outputs# Android APK
flutter build apk --release
# Android App Bundle (Play Store)
flutter build appbundle --release
# iOS IPA (requires macOS + Xcode)
flutter build ipa --release| Platform | Minimum | Target |
|---|---|---|
| Android | API 26 (Android 8.0) | API 34 (Android 14) |
| iOS | 14.0 | latest |
Two self-contained Relay node implementations — both fully wire-compatible with the Echo app (same BLE UUIDs, GATT protocol, advertisement format).
See beacon_cli/README.md for full setup and command reference.
cd beacon_cli
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
beacon new --headline "Tornado Warning" --severity Extreme \
--expires 1893456000 --instructions "Seek shelter now." --source-url "local://op"
beacon publish <alert_id>
beacon broadcast# Requirements: Python 3.10+, BlueZ, D-Bus
sudo apt-get update
sudo apt-get install -y bluetooth bluez python3-dbus python3-venv
cd pi_sender
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
beconnect-pi alert new \
--headline "Severe Thunderstorm Warning" \
--severity Severe \
--expires 1893456000 \
--instructions "Move indoors immediately. Avoid windows." \
--source-url "local://operator" \
--verified false
beconnect-pi publish <alert_id>
beconnect-pi broadcast start
beconnect-pi status| File | Contents |
|---|---|
alerts.json |
All saved alerts |
current_alert.json |
The currently published alert |
broadcaster.pid |
PID of the background daemon |
broadcaster.log |
Daemon log output |
The broadcaster polls current_alert.json every 2 seconds — running beconnect-pi publish while the daemon is running hot-swaps the alert without a restart.
- BLE advertising requires a physical device — Android emulators do not support
BluetoothLeAdvertiser. - The background service runs as a foreground service with a persistent notification (mandatory on Android 14+ with
foregroundServiceType: connectedDevice). ACCESS_FINE_LOCATIONis required for BLE scanning on some devices even withusesPermissionFlags="neverForLocation"set.- Impeller is disabled in the manifest (
EnableImpeller = false) for compatibility withflutter_background_service.
- BLE scanning requires the app to be in the foreground unless
bluetooth-centralbackground mode is active (it is, perInfo.plist). CBPeripheralManagersilently drops custom manufacturer data when advertising. The service UUID is still broadcast and is sufficient for filtering.- The iOS Simulator does not support Bluetooth — always test on a physical device.
- Translation requires iOS 18.0+. On older versions, selecting a language shows a snackbar explaining the requirement.
- iOS
withServiceshardware scan filter can stripserviceUuidsfrom delivered advertisement data — the scanner filters in software and also accepts devices by BLE name"BeConnect"as a fallback.
| Platform | Engine | Notes |
|---|---|---|
| iOS | AVSpeechSynthesizer |
Always available; no permissions required |
| Android | android.speech.tts.TextToSpeech |
Present on all standard installs; no permissions required |
Speech rate is set to 0.45× (slightly below default) for clear, deliberate reading — optimized for comprehension during an emergency. The language is automatically set to match the selected translation.
| Symptom | Likely cause | Fix |
|---|---|---|
| Scan returns no results (Android) | ACCESS_FINE_LOCATION denied or location services off |
Grant permission; enable Location in device Settings |
| Scan returns no results (iOS) | App backgrounded or BT permission not granted | Bring to foreground; check NSBluetoothAlwaysUsageDescription |
| Relay node not detected on iOS | withServices hardware filter stripping service UUIDs |
Already handled — scanner falls back to matching BLE device name "BeConnect" |
| Advertising fails silently (Android) | Device doesn't support multiple advertisements | Use a physical device; call isMultipleAdvertisementSupported() to verify |
| GATT error 133 on first connect | Common Android race condition | Already handled — GattClient retries once after 600ms |
| Chunks reassemble to garbled JSON | Chunk size mismatch | Confirm native gateway uses 508-byte payload (512 MTU − 4 header bytes) |
| Background service not running | Permissions not granted or battery optimization on | Grant all permissions; disable battery optimization in Android Settings |
MissingPluginException in background |
GattServer.start() called from background isolate |
All GattServer calls must stay in the main isolate; use IPC events instead |
| "Timed out waiting for CONFIGURATION_BUILD_DIR" | Transient Xcode bug | pkill Xcode, then re-run flutter run |
End-to-end: from zero to alert on a second phone in under 60 seconds.
Requirements: one source device (Relay node or phone with Wi-Fi) + one or more receiver phones with Bluetooth ON and Wi-Fi OFF.
beacon new --headline "Demo Tornado Warning — seek shelter now" \
--severity Extreme \
--expires 9999999999 \
--instructions "Go to the lowest floor of a sturdy building. Avoid windows." \
--source-url "local://demo"
beacon publish <alert_id>
beacon broadcast- Open Echo on a phone with Wi-Fi ON.
- Tap the gear icon → Download NWS Alerts (live data) or Load Demo Alerts (offline test).
- The app begins advertising automatically.
- Open Echo on a second phone with Wi-Fi OFF and Bluetooth ON.
- The app auto-scans within the first 15 seconds of launch.
- The alert card appears with a green NWS tag (if live data) or dim DEMO tag (if demo).
- Tap the card → tap Read Aloud to hear the alert via native TTS.
- Tap the language picker to translate the instructions offline.
- Let Echo A (1 echo) remain open and running.
- Bring a third phone (Echo B) within range of Echo A but out of range of the Relay.
- Echo B receives the alert with
hopCount: 2. - The Echo Path in the detail screen shows:
🟢 ── ⚪ ── 📱(origin → Echo A → Echo B).
Internal / hackathon project. Not for public distribution.


