mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
128
docs/google-play-release.md
Normal file
128
docs/google-play-release.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Google Play Release Runbook
|
||||
|
||||
## Purpose
|
||||
|
||||
This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## Distribution Modes
|
||||
|
||||
- GitHub release: signed APK for direct install or manual distribution.
|
||||
- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production.
|
||||
|
||||
## Required Secrets and Local Files
|
||||
|
||||
GitHub Actions secrets for `dz0ny/meshcore-sar`:
|
||||
|
||||
- `ANDROID_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
|
||||
|
||||
Local files used to seed the secrets:
|
||||
|
||||
- `android/key.properties`
|
||||
- release keystore referenced by `android/key.properties`
|
||||
- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json`
|
||||
|
||||
The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## CI Release Flow
|
||||
|
||||
The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published.
|
||||
|
||||
Android release steps:
|
||||
|
||||
1. Validate all Android signing and Google Play secrets.
|
||||
2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets.
|
||||
3. Build the signed APK with `flutter build apk --release`.
|
||||
4. Build the signed Play AAB with `flutter build appbundle --release`.
|
||||
5. Upload the APK and AAB as GitHub release assets.
|
||||
6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`.
|
||||
7. Upload the AAB to Play production when the GitHub release is not marked as prerelease.
|
||||
|
||||
The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- Version source of truth: `pubspec.yaml`.
|
||||
- Android `versionCode` is the number after `+`.
|
||||
- `versionCode` must always increase for Play uploads.
|
||||
- `make bump`, `make build`, and `make bundle` increment the version.
|
||||
- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally.
|
||||
|
||||
## Manual Build Commands
|
||||
|
||||
Run commands from the repo root.
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
Repo shortcuts:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make bundle
|
||||
make build-no-bump
|
||||
make bundle-no-bump
|
||||
```
|
||||
|
||||
## Fastlane Lanes
|
||||
|
||||
Run commands from `android/`.
|
||||
|
||||
```bash
|
||||
bundle exec fastlane android direct_apk
|
||||
bundle exec fastlane android internal
|
||||
bundle exec fastlane android production
|
||||
```
|
||||
|
||||
Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it.
|
||||
|
||||
```bash
|
||||
SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- APK: `build/app/outputs/flutter-apk/app-release.apk`
|
||||
- AAB: `build/app/outputs/bundle/release/app-release.aab`
|
||||
- Release APK asset: `meshcore-sar-<tag>-android.apk`
|
||||
- Release AAB asset: `meshcore-sar-<tag>-play.aab`
|
||||
|
||||
## Internal Testing Release Checklist
|
||||
|
||||
1. Confirm `pubspec.yaml` version is correct and `versionCode` increased.
|
||||
2. Confirm `android/key.properties` points to the release keystore for local builds.
|
||||
3. Confirm all five GitHub Actions secrets exist.
|
||||
4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion.
|
||||
5. Install from the internal testing track on a real Android device.
|
||||
6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior.
|
||||
|
||||
## Production Release Checklist
|
||||
|
||||
1. Complete internal testing validation first.
|
||||
2. Confirm Play Console forms are current:
|
||||
- App content
|
||||
- Data safety
|
||||
- App access
|
||||
- Ads declaration
|
||||
- Content rating
|
||||
3. Confirm store assets are current:
|
||||
- app icon
|
||||
- feature graphic
|
||||
- phone screenshots
|
||||
- tablet screenshots if used
|
||||
- support URL
|
||||
- privacy policy URL
|
||||
4. Publish a non-prerelease GitHub release.
|
||||
5. Confirm the GitHub release has the APK and AAB assets.
|
||||
6. Confirm Play Console has the new internal and production release entries.
|
||||
|
||||
## Rollback Rules
|
||||
|
||||
- Never reuse or lower an Android `versionCode`.
|
||||
- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix.
|
||||
- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play.
|
||||
324
docs/image-mode-technical.md
Normal file
324
docs/image-mode-technical.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Image Mode Technical Design
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Image mode mirrors the voice on-demand architecture exactly, including
|
||||
swarm-assisted recovery for stalled partial transfers:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `IE4:` image envelope announces image availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary image fetch request (same raw route as image fragments).
|
||||
- **Data plane (raw binary packets):**
|
||||
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
||||
|
||||
Images are never broadcast in full to channels. Chat carries only metadata;
|
||||
pixels are fetched on demand when the user taps the image bubble.
|
||||
|
||||
Shared swarm fallback is documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/image_message_parser.dart`
|
||||
- `ImagePacket` (binary fragment format)
|
||||
- `ImageEnvelope` (`IE4`)
|
||||
- `ImageFetchRequest` (binary)
|
||||
- `fragmentImage()` — split compressed bytes into packets
|
||||
- `reassembleImage()` — join received fragments into bytes
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Pick image from gallery or camera, compress with user settings, cache, send envelope
|
||||
- `lib/providers/image_provider.dart`
|
||||
- Reassembly sessions, outgoing cache, deferred serving
|
||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
||||
- Received partial sessions can be re-served during swarm recovery
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads
|
||||
- `lib/widgets/messages/image_message_bubble.dart`
|
||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
||||
- `lib/services/image_codec_service.dart`
|
||||
- `ImageCodecService.compress()` — aspect-ratio-preserving resize + optional
|
||||
grayscale + AVIF encode via `flutter_avif`
|
||||
- `lib/services/image_preferences.dart`
|
||||
- `ImagePreferences` — persists max size, compression level, grayscale toggle
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Image Envelope (`IE4`)
|
||||
|
||||
Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------|
|
||||
| `sid` | string | base36 token for 32-bit session ID |
|
||||
| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||
| `total` | base36 | Fragment count (1..255) |
|
||||
| `w` | base36 | Actual image width after compression (pixels) |
|
||||
| `h` | base36 | Actual image height after compression (pixels) |
|
||||
| `bytes` | base36 | Total compressed size in bytes |
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||
```
|
||||
|
||||
Example (256×171 landscape image, 14 fragments):
|
||||
|
||||
```text
|
||||
IE4:a:0:e:74:4r:1mc
|
||||
```
|
||||
|
||||
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||
the source aspect ratio (contain within the configured max size).
|
||||
|
||||
### 3.2 Image Fetch Request (`IR4` + binary)
|
||||
|
||||
Text format:
|
||||
|
||||
```text
|
||||
IR4:{sid}:{want}:{requesterKey6}
|
||||
```
|
||||
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
|------------------|--------------------------|
|
||||
| `flags` | bit0=1 => request missing indices, else all |
|
||||
| `requesterKey6` | 6-byte requester key prefix |
|
||||
### 3.3 Raw Image Packet (data plane)
|
||||
|
||||
Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x49` (`'I'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: fragment index (0-based)
|
||||
- Bytes 6..N: image data (max 152 bytes per fragment)
|
||||
|
||||
Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope.
|
||||
|
||||
## 4. Compression Pipeline
|
||||
|
||||
`ImageCodecService.compress(rawBytes, {maxDimension, compression, grayscale})`:
|
||||
|
||||
1. **Probe** — decode source image at original resolution to read `srcW × srcH`.
|
||||
2. **Contain** — compute `dstW × dstH` that fits within `maxDimension × maxDimension`
|
||||
while preserving aspect ratio. Images smaller than the cap are not upscaled.
|
||||
3. **Decode** — `dart:ui.instantiateImageCodec` with `targetWidth: dstW, targetHeight: dstH`.
|
||||
4. **RGBA export** — `image.toByteData(format: rawRgba)`.
|
||||
5. **Grayscale** *(optional, default on)* — luminance `0.299R + 0.587G + 0.114B`
|
||||
applied in-place; if disabled, colour pixels are passed through unchanged.
|
||||
6. **PNG re-encode** — `ui.ImageDescriptor.raw` → `toByteData(format: png)`.
|
||||
Required because `encodeAvif()` takes an encoded image, not raw RGBA.
|
||||
7. **AVIF encode** — `flutter_avif.encodeAvif(pngBytes, maxQuantizer, minQuantizer, speed: 8)`.
|
||||
`compression` (10–90) maps to libavif CQ scale:
|
||||
`maxQ = round(compression/100 × 63)`, `minQ = round(maxQ × 0.65)`.
|
||||
8. **Fragment** — `fragmentImage()` at 152 bytes/packet (≤255 packets).
|
||||
|
||||
Returns `({Uint8List bytes, int width, int height})` with the actual compressed dimensions.
|
||||
|
||||
### User-configurable settings (`ImagePreferences`)
|
||||
|
||||
| Setting | Key | Default | Range / Options |
|
||||
|--------------|--------------------|---------|-----------------|
|
||||
| Max size | `image_max_size` | 256 | 64 / 128 / 256 |
|
||||
| Compression | `image_quality` | 90 | 10–90 |
|
||||
| Grayscale | `image_grayscale` | true | true / false |
|
||||
|
||||
### Expected sizes (grayscale AVIF, compression 90)
|
||||
|
||||
| Max size | Typical size | Fragments |
|
||||
|----------|--------------|-----------|
|
||||
| 64×64 | 100–400 B | 1–3 |
|
||||
| 128×128 | 300–900 B | 2–6 |
|
||||
| 256×256 | 700–3000 B | 5–20 |
|
||||
|
||||
Non-square images produce fewer fragments than the square equivalent because
|
||||
only the shorter axis is padded — no cropping occurs.
|
||||
|
||||
## 5. Outgoing Flow (Send)
|
||||
|
||||
1. User picks image from gallery or camera (`image_picker`).
|
||||
2. `ImagePreferences` is read: max size, compression, grayscale.
|
||||
3. `ImageCodecService.compress()` returns `(bytes, width, height)`.
|
||||
4. `fragmentImage()` splits bytes into `ImagePacket` list (≤255 fragments).
|
||||
5. `ImageProvider.cacheOutgoingSession()` stores fragments in both the outgoing
|
||||
cache (for serving to peers) and the incoming session map (so the local bubble
|
||||
renders the image immediately without a fetch round-trip).
|
||||
6. `ImageEnvelope` built with actual `width`/`height` from step 3.
|
||||
7. Envelope sent via normal message path:
|
||||
- Channel: `sendChannelMessage`
|
||||
- Direct: `sendTextMessage`
|
||||
8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
|
||||
|
||||
## 6. Incoming Flow (Receive)
|
||||
|
||||
### 6.1 `IE4` envelope received
|
||||
|
||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||
chat. The bubble shows a grey square placeholder with a download icon.
|
||||
|
||||
### 6.2 Binary image fetch request received
|
||||
|
||||
`AppProvider` treats it as control-plane only (not added to chat):
|
||||
|
||||
- Resolves requester contact.
|
||||
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
|
||||
|
||||
### 6.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles shared raw swarm discovery payloads:
|
||||
|
||||
- binary swarm requests advertise which image fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments another peer can relay
|
||||
- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat
|
||||
|
||||
Shared discovery and responder semantics are documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 6.4 Raw packet received (`pushRawData`, magic `0x49`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
|
||||
`imageProvider.addFragment()`. When the session becomes complete, the bubble
|
||||
automatically rebuilds with the full image.
|
||||
|
||||
## 7. Outgoing Cache Details
|
||||
|
||||
`ImageProvider` outgoing cache:
|
||||
|
||||
- key: `sessionId`
|
||||
- value: encoded fragment list + cached envelope + timestamp
|
||||
- TTL: 15 minutes
|
||||
- eviction: lazy on access
|
||||
|
||||
When `cacheOutgoingSession()` is called it also writes all fragments into
|
||||
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble
|
||||
(no tap-to-load required for own messages).
|
||||
|
||||
If a peer later receives only part of an image session, those received fragments
|
||||
can also be served onward to another requester during swarm recovery.
|
||||
|
||||
## 8. Display
|
||||
|
||||
`ImageMessageBubble` (max width 256 px):
|
||||
|
||||
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
||||
tap → sends binary fetch request, with raw swarm fallback if the original
|
||||
sender path stalls.
|
||||
- **Loading**: circular progress indicator showing `received/total` count.
|
||||
- **Error**: broken-image icon.
|
||||
|
||||
Status line below thumbnail:
|
||||
|
||||
- Sender: `🖼️ {w}×{h} AVIF · {n} seg`
|
||||
- Receiver (complete): `🖼️ {w}×{h} AVIF`
|
||||
- Receiver (pending): `🖼️ Tap to load · {w}×{h}`
|
||||
- Loading: `📥 Loading… {received}/{total}`
|
||||
|
||||
## 9. Transmit Time Estimate (UI)
|
||||
|
||||
Image bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`).
|
||||
|
||||
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||
|
||||
- Source inputs:
|
||||
- `total` fragments and `bytes` from `IE4` envelope
|
||||
- all numeric envelope values are decoded from base36
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-fragment payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
- multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0`
|
||||
- multiplied by hop count `(pathLen + 1)`
|
||||
- Total estimate:
|
||||
- sum over all fragments
|
||||
|
||||
BW handling:
|
||||
|
||||
- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`)
|
||||
- If `radioBw > 1000`, it is treated as Hz directly
|
||||
|
||||
Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
`ImageProvider` stores sessions in `SharedPreferences` under key
|
||||
`stored_image_sessions_v1`:
|
||||
|
||||
- Incoming: fragment list serialized as base64 binary packets.
|
||||
- Outgoing: fragment list + envelope text + `cachedAt` timestamp.
|
||||
- Expired outgoing sessions (> 15 min) are not restored on startup.
|
||||
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
|
||||
- On-demand fetch prefers the original sender, but a partial image can also be
|
||||
completed from alternate peers that already hold matching fragments.
|
||||
- Raw return path requires a valid direct route to requester.
|
||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image
|
||||
fetch and fragment delivery.
|
||||
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||
- This flow is direct-route only, not flood/broadcast:
|
||||
- it is sent to one destination path;
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 12. Swarm Fallback Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Original Sender
|
||||
participant P as Peer With Fragments
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>M: Send IE4 envelope
|
||||
M->>B: Deliver IE4
|
||||
B->>A: Direct binary image fetch request
|
||||
A->>B: Stream raw ImagePacket fragments (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing image fragment indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw ImagePacket fragments
|
||||
B->>B: Reassemble completed image
|
||||
```
|
||||
|
||||
## 13. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Sender App
|
||||
participant M as Mesh Chat
|
||||
participant B as Receiver App
|
||||
|
||||
A->>A: Pick image (gallery or camera)
|
||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||
A->>A: Fragment into ≤152B packets
|
||||
A->>A: Cache outgoing + populate local session (immediate display)
|
||||
A->>M: Send IE4 envelope (actual w×h, fragment count)
|
||||
M->>B: Deliver IE4
|
||||
B->>B: Render grey placeholder bubble
|
||||
B->>A: Tap → send binary fetch request
|
||||
A->>B: Stream binary ImagePackets
|
||||
B->>B: Reassemble fragments
|
||||
B->>B: Display AVIF image (cover thumbnail)
|
||||
```
|
||||
148
docs/swarm-mode-technical.md
Normal file
148
docs/swarm-mode-technical.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Swarm Mode Technical Design
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Swarm mode is a shared media-recovery transport used by both voice and image
|
||||
sessions when the original sender path stops responding after some fragments
|
||||
have already propagated through the mesh.
|
||||
|
||||
It adds a lightweight **swarm discovery plane** on top of the existing
|
||||
**direct raw-data transfer plane**:
|
||||
|
||||
- **Discovery plane (raw custom control payloads):**
|
||||
- `MediaSwarmRequest`
|
||||
- `MediaSwarmAvailability`
|
||||
- **Transfer plane (direct raw binary):**
|
||||
- existing `VoiceFetchRequest` / `ImageFetchRequest`
|
||||
- existing `VoicePacket` / `ImagePacket`
|
||||
|
||||
Swarm mode does not broadcast media payloads. It fans out raw control requests
|
||||
to reachable peers, collects raw availability responses, and then fetches media
|
||||
from the best responder.
|
||||
|
||||
## 2. Problem It Solves
|
||||
|
||||
Without swarm mode, media fetch is limited to the original sender's direct raw
|
||||
path. If that sender goes offline, moves, or stops responding, a receiver can
|
||||
be left with a partial session even when other peers already hold useful
|
||||
fragments.
|
||||
|
||||
Swarm mode lets the receiver discover alternate peers and fetch the missing
|
||||
subset directly from them.
|
||||
|
||||
## 3. Control Messages
|
||||
|
||||
### 3.1 Media Swarm Request (binary)
|
||||
|
||||
```text
|
||||
[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `mediaType` — `voice` or `image`
|
||||
- `sessionId` — 8 hex chars
|
||||
- `requesterKey6` — 12 hex chars identifying the requesting device
|
||||
- `missingCount` — `0` means the requester needs all fragments
|
||||
- `missingIndices` — exact missing fragment indices when `missingCount > 0`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09
|
||||
```
|
||||
|
||||
### 3.2 Media Swarm Availability (binary)
|
||||
|
||||
```text
|
||||
[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...]
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `mediaType` — `voice` or `image`
|
||||
- `sessionId` — 8 hex chars
|
||||
- `requesterKey6` — copied from the swarm request
|
||||
- `responderKey6` — 12 hex chars identifying the responding peer
|
||||
- `availableCount` — `0` means the responder can satisfy the full request
|
||||
- `availableIndices` — exact fragment indices held when `availableCount > 0`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09
|
||||
```
|
||||
|
||||
## 4. Requester Behavior
|
||||
|
||||
When a voice or image session is incomplete:
|
||||
|
||||
1. Prefer the original sender if its direct raw route is healthy.
|
||||
2. If the original sender path is unavailable or not responding, send a raw
|
||||
swarm request to reachable peers with the exact missing fragment indices.
|
||||
3. Wait up to **10 seconds** for raw availability responses.
|
||||
4. Rank responders by overlap with the current missing set.
|
||||
5. Skip the original sender when choosing alternate peers.
|
||||
6. Send a direct binary fetch request to the best responder for only the
|
||||
missing subset that responder advertised.
|
||||
|
||||
Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the
|
||||
swarm control messages.
|
||||
|
||||
## 5. Responder Behavior
|
||||
|
||||
A peer that receives a swarm request:
|
||||
|
||||
- checks whether it has fragments for the requested media session
|
||||
- replies only if it has at least one requested fragment
|
||||
- advertises only fragments it actually holds
|
||||
- may reply from:
|
||||
- its outgoing cache, or
|
||||
- a partially/fully received incoming session
|
||||
|
||||
This enables torrent-like relay behavior without requiring the peer to be the
|
||||
original sender.
|
||||
|
||||
## 6. Visibility and Routing
|
||||
|
||||
- Swarm control payloads are carried by `cmdSendRawData` and received through
|
||||
`pushRawData`.
|
||||
- They are intercepted by the app and **must not be surfaced in chat**.
|
||||
- Discovery is a direct raw fan-out to reachable peers, not a public-channel
|
||||
broadcast.
|
||||
- Media packets themselves remain direct-route raw packets.
|
||||
|
||||
## 7. Constraints
|
||||
|
||||
- No firmware changes are required.
|
||||
- Swarm mode only helps if at least one peer has already received some useful
|
||||
fragments.
|
||||
- Peers can only relay fragments they actually have.
|
||||
- Alternate peers still need a valid direct raw route back to the requester.
|
||||
- Swarm mode improves recovery from sender-path failure, but it does not change
|
||||
the underlying raw-packet size, airtime, or hop constraints.
|
||||
|
||||
## 8. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Original Sender
|
||||
participant P as Peer With Fragments
|
||||
participant N as Reachable Peers
|
||||
participant R as Requester
|
||||
|
||||
S->>R: Direct raw fragments (partial)
|
||||
Note over S,R: Original sender path stops responding
|
||||
R->>N: Raw swarm requests with exact missing fragments
|
||||
P->>R: Raw swarm availability response
|
||||
R->>P: Direct binary fetch request for missing subset
|
||||
P->>R: Direct raw fragments
|
||||
R->>R: Reassemble completed session
|
||||
```
|
||||
|
||||
## 9. Integration Points
|
||||
|
||||
- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`.
|
||||
- Image-specific behavior is summarized in `docs/image-mode-technical.md`.
|
||||
- This document is the shared source of truth for swarm discovery and fallback
|
||||
semantics.
|
||||
255
docs/voice-mode-technical.md
Normal file
255
docs/voice-mode-technical.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Voice Mode Technical Design
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Voice mode uses a **two-plane architecture** with optional swarm-assisted
|
||||
recovery:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `VE3:` voice envelope announces voice availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary voice fetch request (same raw route as voice packets).
|
||||
- **Data plane (raw binary packets):**
|
||||
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
||||
|
||||
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
|
||||
|
||||
Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/voice_message_parser.dart`
|
||||
- `VoicePacket` (binary direct-packet format)
|
||||
- `VoiceEnvelope` (`VE3`)
|
||||
- `VoiceFetchRequest` (binary)
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Capture/encode voice, cache encoded packets, send envelope only
|
||||
- `lib/providers/voice_provider.dart`
|
||||
- Reassembly/playback sessions
|
||||
- Outgoing session cache + deferred serving
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads
|
||||
- Handles raw packet ingestion
|
||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||
- `lib/providers/messages_provider.dart`
|
||||
- Message-level voice detection (`VE3`)
|
||||
- `lib/services/message_storage_service.dart`
|
||||
- Persists `isVoice` and `voiceId`
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Voice Envelope (`VE3`)
|
||||
|
||||
Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
- `sid` (string): base36 token for 32-bit session ID
|
||||
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||
- `total` (base36): packet count (1..255)
|
||||
- `durS` (base36): estimated duration in seconds
|
||||
|
||||
`sid` is base36 on wire and expands to 8-hex internally.
|
||||
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
VE3:{sid}:{mode}:{total}:{durS}
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
VE3:a:1:4:4
|
||||
```
|
||||
|
||||
### 3.2 Voice Fetch Request (binary)
|
||||
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
### 3.3 Raw Voice Packet (data plane)
|
||||
|
||||
Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x56` (`'V'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: packet index
|
||||
- Bytes 6..N: codec2 data
|
||||
|
||||
Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
|
||||
|
||||
## 4. Outgoing Flow (Send)
|
||||
|
||||
1. Recorder captures PCM chunks.
|
||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
||||
5. Sender sends one envelope (`VE3`) through normal message path:
|
||||
- channel/room: `sendChannelMessage`
|
||||
- direct: `sendTextMessage`
|
||||
6. **No raw audio packets are sent during initial send.**
|
||||
|
||||
## 5. Incoming Routing
|
||||
|
||||
### 5.1 `VE3` envelope received
|
||||
|
||||
`AppProvider` records sender identity from message metadata, registers the voice
|
||||
session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat.
|
||||
|
||||
### 5.2 Binary voice fetch request received
|
||||
|
||||
`AppProvider` treats it as control-plane only:
|
||||
|
||||
- request is not added to chat
|
||||
- resolves requester contact via key prefix
|
||||
- calls `voiceProvider.serveSessionTo(...)`
|
||||
|
||||
### 5.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles raw swarm control payloads:
|
||||
|
||||
- binary swarm requests advertise which voice fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments a peer can relay
|
||||
- swarm control payloads arrive via `pushRawData` and are not added to chat history
|
||||
|
||||
Swarm semantics are shared with image mode and documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 5.4 Raw packet received (`pushRawData`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
|
||||
|
||||
## 6. Play / Fetch Behavior
|
||||
|
||||
In `VoiceMessageBubble`:
|
||||
|
||||
- If session already complete: play immediately.
|
||||
- If incomplete/missing:
|
||||
1. Resolve sender contact from message sender metadata
|
||||
2. Prefer a direct fetch from the original sender if its raw route is healthy
|
||||
3. If the sender path does not respond, fan out a raw swarm request with the
|
||||
exact missing packet indices to reachable peers
|
||||
4. Wait up to 10 seconds for raw peer availability responses
|
||||
5. Send a direct binary fetch request to the best alternate peer for the
|
||||
missing subset it advertised
|
||||
6. Show requesting state in UI
|
||||
7. Auto-play when session becomes complete
|
||||
|
||||
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
|
||||
|
||||
## 7. Outgoing Cache Details
|
||||
|
||||
`VoiceProvider` outgoing cache:
|
||||
|
||||
- key: `sessionId`
|
||||
- value: encoded packet list + cached timestamp
|
||||
- TTL: 15 minutes (`_outgoingSessionTtl`)
|
||||
- eviction: lazy (on cache access/add/serve)
|
||||
|
||||
Serving prerequisites:
|
||||
|
||||
- session exists in outgoing cache or already-received session state
|
||||
- `sendRawPacketCallback` configured
|
||||
- requester has direct path (`outPathLen >= 0`)
|
||||
|
||||
Received partial sessions can therefore act as relay sources during swarm
|
||||
recovery.
|
||||
|
||||
## 8. Persistence
|
||||
|
||||
`MessageStorageService` now stores and restores:
|
||||
|
||||
- `isVoice`
|
||||
- `voiceId`
|
||||
|
||||
This ensures envelope messages remain voice bubbles across app restart.
|
||||
|
||||
## 9. Validation and Safety
|
||||
|
||||
Parser validation enforces:
|
||||
|
||||
- strict hex lengths for IDs and key prefixes
|
||||
- valid mode range
|
||||
- valid packet counts and duration bounds
|
||||
- compact base36 numeric fields in envelope/request
|
||||
- Binary request flags specify `all` or `missing` indices.
|
||||
- Request payload includes `requesterKey6` to resolve return route.
|
||||
|
||||
## 10. Transmit Time Estimate (UI)
|
||||
|
||||
Voice bubbles and Message Technical Details show an **estimated transmit time** (`~... tx`).
|
||||
|
||||
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||
|
||||
- Source inputs:
|
||||
- `packetCount` and `durationMs` from `VE3` envelope, or
|
||||
- numeric envelope values decoded from base36
|
||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-packet payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
- multiplied by `(1 + airtimeBudgetFactor)` where default factor is `1.0`
|
||||
- multiplied by hop count `(pathLen + 1)`
|
||||
- Total estimate:
|
||||
- sum over all packets
|
||||
|
||||
BW handling:
|
||||
|
||||
- If `radioBw` is index `0..9`, app maps it to Hz (`7.8k` .. `500k`)
|
||||
- If `radioBw > 1000`, it is treated as Hz directly
|
||||
|
||||
Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`, `CR5`.
|
||||
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required.
|
||||
- On-demand fetch prefers the original sender, but a partial session can also be
|
||||
completed from alternate peers that already hold packets.
|
||||
- Raw return path needs a currently valid direct route to requester.
|
||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice
|
||||
fetch and packet delivery.
|
||||
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||
- This flow is direct-route only, not flood/broadcast:
|
||||
- it is sent to one destination path;
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 12. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Sender App
|
||||
participant P as Peer With Packets
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>A: Record + encode voice packets
|
||||
A->>A: Cache session packets (TTL 15m)
|
||||
A->>M: Send VE3 envelope
|
||||
M->>B: Deliver VE3
|
||||
B->>B: Render voice bubble (metadata only)
|
||||
B->>A: Send binary fetch request on Play
|
||||
A->>B: Stream raw VoicePacket packets (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing voice packet indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw VoicePacket packets
|
||||
B->>B: Reassemble session
|
||||
B->>B: Auto-play when complete
|
||||
```
|
||||
Reference in New Issue
Block a user