Compare commits

..

9 Commits

Author SHA1 Message Date
Janez T
ab0c531a4b Add toast for image fetch failures 2026-03-05 13:07:53 +01:00
Janez T
9709511e90 Fix image bubble contact lookup 2026-03-05 11:56:34 +01:00
Janez T
162d5333ce Hide tab bar when single tab 2026-03-05 11:51:23 +01:00
Janez T
0cb1d49804 Fix null check crash in TabBar 2026-03-05 11:41:15 +01:00
Janez T
234edf5bb0 Add message clear option 2026-03-05 11:04:41 +01:00
Janez T
2d9cb0ddb9 Add tic tac toe DM game 2026-03-05 09:57:14 +01:00
Janez T
76b093685e Add fragment transfer estimate 2026-03-05 09:38:39 +01:00
Janez T
55a85659d3 Add tic tac toe DM game 2026-03-05 09:05:18 +01:00
Janez T
972a9ba944 Document raw binary routing 2026-03-05 08:51:18 +01:00
26 changed files with 2476 additions and 641 deletions

View File

@@ -5,8 +5,9 @@
Image mode mirrors the voice on-demand architecture exactly:
- **Control plane (text messages):**
- `IE1:` image envelope announces image availability in chat.
- `IR1:` direct fetch request asks sender to stream image fragments.
- `IE2:` 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`.
@@ -17,8 +18,8 @@ pixels are fetched on demand when the user taps the image bubble.
- `lib/utils/image_message_parser.dart`
- `ImagePacket` (binary fragment format)
- `ImageEnvelope` (`IE1`)
- `ImageFetchRequest` (`IR1`)
- `ImageEnvelope` (`IE2`)
- `ImageFetchRequest` (binary)
- `fragmentImage()` — split compressed bytes into packets
- `reassembleImage()` — join received fragments into bytes
- `lib/screens/messages_tab.dart`
@@ -27,7 +28,7 @@ pixels are fetched on demand when the user taps the image bubble.
- Reassembly sessions, outgoing cache, deferred serving
- Outgoing sessions also registered as complete incoming sessions for immediate local display
- `lib/providers/app_provider.dart`
- Incoming routing for `IE1`, `IR1`, binary `0x49` packets
- Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets
- `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
@@ -39,52 +40,52 @@ pixels are fetched on demand when the user taps the image bubble.
## 3. Wire Formats
### 3.1 Image Envelope (`IE1`)
### 3.1 Image Envelope (`IE2`)
Prefix: `IE1:` + colon-delimited payload
Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields)
Fields:
| Field | Type | Description |
|--------------|--------|------------------------------------------------|
| `sid` | string | 8 hex chars (4 bytes), session ID |
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
| `total` | int | Fragment count (1..255) |
| `w` | int | Actual image width after compression (pixels) |
| `h` | int | Actual image height after compression (pixels) |
| `bytes` | int | Total compressed size in bytes |
| `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 |
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
| `ts` | int | Unix timestamp (seconds) |
| `ver` | int | Protocol version (currently `1`) |
| `ts` | base36 | Unix timestamp (seconds) |
Compact format:
```text
IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
```
Example (256×171 landscape image, 14 fragments):
```text
IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1
IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8
```
Note: `w` and `h` reflect the actual post-compression dimensions, which preserve
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 (`IR1`)
### 3.2 Image Fetch Request (binary)
Same structure as `VR1`:
Binary payload format:
```text
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
```
| Field | Value |
|------------------|--------------------------|
| `want` | `a` (= "all fragments") |
| `requesterKey6` | 12 hex chars |
| `ver` | `1` |
| `flags` | bit0=1 => request missing indices, else all |
| `requesterKey6` | 6-byte requester key prefix |
| `ts` | unix timestamp seconds (u32) |
### 3.3 Raw Image Packet (data plane)
@@ -151,16 +152,16 @@ only the shorter axis is padded — no cropping occurs.
7. Envelope sent via normal message path:
- Channel: `sendChannelMessage`
- Direct: `sendTextMessage`
8. Local placeholder message added (`IE1:` text, `deliveryStatus.sending`).
8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`).
## 6. Incoming Flow (Receive)
### 6.1 `IE1` envelope received
### 6.1 `IE2` 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 `IR1` request received
### 6.2 Binary image fetch request received
`AppProvider` treats it as control-plane only (not added to chat):
@@ -194,7 +195,7 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
- **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 `IR1` fetch request.
tap → sends binary fetch request.
- **Loading**: circular progress indicator showing `received/total` count.
- **Error**: broken-image icon.
@@ -212,7 +213,8 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not just compressed image size:
- Source inputs:
- `total` fragments and `bytes` from `IE1` envelope
- `total` fragments and `bytes` from `IE2` 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:
@@ -248,6 +250,16 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- Raw return path requires a valid direct route to requester.
- Available on iOS and Android (`image_picker` + `flutter_avif`).
### 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. High-Level Sequence
```mermaid
@@ -260,10 +272,10 @@ sequenceDiagram
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 IE1 envelope (actual w×h, fragment count)
M->>B: Deliver IE1
A->>M: Send IE2 envelope (actual w×h, fragment count)
M->>B: Deliver IE2
B->>B: Render grey placeholder bubble
B->>A: Tap → send IR1 fetch request
B->>A: Tap → send binary fetch request
A->>B: Stream binary ImagePackets
B->>B: Reassemble fragments
B->>B: Display AVIF image (cover thumbnail)

View File

@@ -5,8 +5,9 @@
Voice mode uses a **two-plane architecture**:
- **Control plane (text messages):**
- `VE1:` voice envelope announces voice availability in chat.
- `VR1:` direct fetch request asks sender to stream voice payload.
- `VE2:` 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`.
@@ -16,73 +17,58 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
- `lib/utils/voice_message_parser.dart`
- `VoicePacket` (legacy text + binary packet format)
- `VoiceEnvelope` (`VE1`)
- `VoiceFetchRequest` (`VR1`)
- `VoiceEnvelope` (`VE2`)
- `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 `VE1` and `VR1`
- Incoming routing for `VE2` and binary voice fetch requests
- 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 (`VE1` + legacy `V:`)
- Message-level voice detection (`VE2` + legacy `V:`)
- `lib/services/message_storage_service.dart`
- Persists `isVoice` and `voiceId`
## 3. Wire Formats
### 3.1 Voice Envelope (`VE1`)
### 3.1 Voice Envelope (`VE2`)
Prefix: `VE1:` + colon-delimited compact payload
Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields)
Fields:
- `sid` (string, 8 hex chars): session ID
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
- `total` (int): packet count (1..255)
- `durMs` (int): estimated duration in ms
- `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
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
- `ts` (int): unix timestamp seconds
- `ver` (int): protocol version (currently `1`)
- `ts` (base36): unix timestamp seconds
`sid` is base36 on wire and expands to 8-hex internally.
Compact format:
```text
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
```
Example:
```text
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1
VE2:a:1:4:4:aabbccddeeff:s44we8
```
### 3.2 Voice Fetch Request (`VR1`)
### 3.2 Voice Fetch Request (binary)
Prefix: `VR1:` + colon-delimited compact payload
Fields:
- `sid` (string, 8 hex chars): requested session
- `want` (string): currently `a` (compact token for `all`)
- `requesterKey6` (string, 12 hex chars): requester key prefix
- `ts` (int): unix timestamp seconds
- `ver` (int): protocol version (`1`)
Compact format:
Binary payload format:
```text
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
```
Example:
```text
VR1:deadbeef:a:112233445566:1700000010:1
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
```
### 3.3 Raw Voice Packet (data plane)
@@ -102,18 +88,18 @@ Binary payload structure:
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 (`VE1`) through normal message path:
5. Sender sends one envelope (`VE2`) through normal message path:
- channel/room: `sendChannelMessage`
- direct: `sendTextMessage`
6. **No raw audio packets are sent during initial send.**
## 5. Incoming Routing
### 5.1 `VE1` envelope received
### 5.1 `VE2` envelope received
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
### 5.2 `VR1` request received
### 5.2 Binary voice fetch request received
`AppProvider` treats it as control-plane only:
@@ -132,8 +118,8 @@ In `VoiceMessageBubble`:
- If session already complete: play immediately.
- If incomplete/missing:
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback)
2. Send direct `VR1` fetch request
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
2. Send direct binary fetch request
3. Show requesting state in UI
4. Auto-play when session becomes complete
@@ -170,10 +156,9 @@ Parser validation enforces:
- strict hex lengths for IDs and key prefixes
- valid mode range
- valid packet counts and duration bounds
- fixed protocol version (`ver == 1`)
- `VR1.want` token `a` (internally normalized to `all`)
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
- 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)
@@ -182,7 +167,8 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not file-duration-only:
- Source inputs:
- `packetCount` and `durationMs` from `VE1` envelope, or
- `packetCount` and `durationMs` from `VE2` 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`
@@ -210,10 +196,20 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- Raw return path needs a currently valid direct route to requester.
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
### 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. Backward Compatibility
- Legacy `V:` text packet parsing is still supported.
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
- Message voice detection accepts `VE2` and legacy `V:` formats.
## 13. High-Level Sequence
@@ -225,10 +221,10 @@ sequenceDiagram
A->>A: Record + encode voice packets
A->>A: Cache session packets (TTL 15m)
A->>M: Send VE1 envelope
M->>B: Deliver VE1
A->>M: Send VE2 envelope
M->>B: Deliver VE2
B->>B: Render voice bubble (metadata only)
B->>A: Send VR1 request on Play
B->>A: Send binary fetch request on Play
A->>B: Stream raw VoicePacket packets
B->>B: Reassemble session
B->>B: Auto-play when complete

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 74;
CURRENT_PROJECT_VERSION = 81;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>74</string>
<string>81</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000195">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.405396">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="101.387002">
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.873722">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591">
</testcase>

View File

@@ -42,6 +42,8 @@ class AppProvider with ChangeNotifier {
bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled;
bool _isContactsEnabled = true;
bool get isContactsEnabled => _isContactsEnabled;
bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -57,8 +59,11 @@ class AppProvider with ChangeNotifier {
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
@@ -78,6 +83,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking();
_loadSimpleMode();
_loadMapEnabled();
_loadContactsEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
@@ -211,6 +217,29 @@ class AppProvider with ChangeNotifier {
}
}
/// Load contacts enabled setting from shared preferences
Future<void> _loadContactsEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isContactsEnabled = prefs.getBool('contacts_enabled') ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading contacts enabled setting: $e');
}
}
/// Toggle contacts tab on/off
Future<void> toggleContactsEnabled(bool enabled) async {
try {
_isContactsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('contacts_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving contacts enabled setting: $e');
}
}
/// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try {
@@ -404,6 +433,26 @@ class AppProvider with ChangeNotifier {
payload: payload,
);
};
voiceProvider.waitForFragmentAckCallback =
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) => _waitForVoiceFragmentAck(
sessionId: sessionId,
index: index,
timeout: timeout,
);
imageProvider.waitForFragmentAckCallback =
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) => _waitForImageFragmentAck(
sessionId: sessionId,
index: index,
timeout: timeout,
);
// When a contact is received from BLE
connectionProvider.onContactReceived = (contact) {
@@ -540,62 +589,6 @@ class AppProvider with ChangeNotifier {
}
}
// Voice control plane: request sender to stream raw voice packets.
final voiceFetchRequest = VoiceFetchRequest.tryParseText(
enrichedMessage.text,
);
if (voiceFetchRequest != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch request without sender prefix',
);
return;
}
final senderPrefixHex = senderPrefix
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (senderPrefixHex.toLowerCase() !=
voiceFetchRequest.requesterKey6.toLowerCase()) {
debugPrint('⚠️ [AppProvider] Voice fetch requester key mismatch');
return;
}
final requester = contactsProvider.findContactByPrefix(senderPrefix);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId,
requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
),
);
return;
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
@@ -665,61 +658,12 @@ class AppProvider with ChangeNotifier {
return;
}
// Image fetch request (IR1): requester asks us to stream image fragments.
final imageFetchRequest = ImageFetchRequest.tryParse(
enrichedMessage.text,
);
if (imageFetchRequest != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix != null) {
final senderPrefixHex = senderPrefix
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (senderPrefixHex.toLowerCase() ==
imageFetchRequest.requesterKey6.toLowerCase()) {
final requester = contactsProvider.findContactByPrefix(
senderPrefix,
);
if (requester != null) {
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId,
requester: requester,
requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet()
: null,
),
);
} else {
debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
}
}
}
return; // IR1 is control-plane only; not displayed in chat
}
// Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) {
_imageSessionSenderKey6[imageEnvelope.sessionId] = imageEnvelope
.senderKey6
.toLowerCase();
imageProvider.registerEnvelope(imageEnvelope);
messagesProvider.addMessage(
enrichedMessage,
@@ -800,8 +744,99 @@ class AppProvider with ChangeNotifier {
};
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Magic 0x72 'r' = voice fetch request; 0x69 'i' = image fetch request.
// Magic 0x56 'V' = voice packet; magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
voiceFetchRequest.requesterKey6,
);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId,
requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
),
);
return;
}
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
if (imageFetchRequest != null) {
final requester = contactsProvider.findContactByPrefixHex(
imageFetchRequest.requesterKey6,
);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found (binary)',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.outPathLen > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.outPathLen} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(
imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId,
requester: requester,
requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet()
: null,
),
);
return;
}
final voiceAck = VoiceFragmentAck.tryParseBinary(payload);
if (voiceAck != null) {
_completeVoiceFragmentAck(voiceAck.sessionId, voiceAck.index);
return;
}
final imageAck = ImageFragmentAck.tryParseBinary(payload);
if (imageAck != null) {
_completeImageFragmentAck(imageAck.sessionId, imageAck.index);
return;
}
if (ImagePacket.isImageBinary(payload)) {
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return;
@@ -812,6 +847,7 @@ class AppProvider with ChangeNotifier {
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_sendImageFragmentAck(frag);
return;
}
@@ -820,6 +856,7 @@ class AppProvider with ChangeNotifier {
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final justComplete = voiceProvider.addPacket(pkt);
_sendVoiceFragmentAck(pkt);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
@@ -1209,15 +1246,18 @@ class AppProvider with ChangeNotifier {
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
version: 2,
);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encodeText(),
contact: sender,
);
if (!sent) return;
try {
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: request.encodeBinary(),
);
} catch (_) {
return;
}
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
_voiceMissingRetryTimers[sessionId]?.cancel();
@@ -1266,6 +1306,102 @@ class AppProvider with ChangeNotifier {
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
}
String _fragmentAckKey(String sessionId, int index) => '$sessionId:$index';
Future<bool> _waitForVoiceFragmentAck({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_voiceFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_voiceFragmentAckWaiters[key] == completer) {
_voiceFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeVoiceFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _voiceFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
Future<bool> _waitForImageFragmentAck({
required String sessionId,
required int index,
Duration timeout = const Duration(seconds: 8),
}) async {
final key = _fragmentAckKey(sessionId, index);
final completer = Completer<void>();
_imageFragmentAckWaiters[key] = completer;
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
if (_imageFragmentAckWaiters[key] == completer) {
_imageFragmentAckWaiters.remove(key);
}
return false;
}
}
void _completeImageFragmentAck(String sessionId, int index) {
final key = _fragmentAckKey(sessionId, index);
final completer = _imageFragmentAckWaiters.remove(key);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
void _sendVoiceFragmentAck(VoicePacket packet) {
final senderKey6 = _voiceSessionSenderKey6[packet.sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
if (sender == null) return;
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: VoiceFragmentAck(
sessionId: packet.sessionId,
index: packet.index,
).encodeBinary(),
),
);
}
void _sendImageFragmentAck(ImagePacket fragment) {
final senderKey6 = _imageSessionSenderKey6[fragment.sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
if (sender == null) return;
if (sender.outPathLen < 0 || sender.outPathLen > _maxDirectPayloadHops) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: ImageFragmentAck(
sessionId: fragment.sessionId,
index: fragment.index,
).encodeBinary(),
),
);
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching

View File

@@ -65,8 +65,8 @@ class ConnectionProvider with ChangeNotifier {
/// Active service — BLE or TCP depending on current mode
MeshCoreServiceBase get _activeService =>
(_connectionMode == ConnectionMode.tcp && _tcpService != null)
? _tcpService!
: _bleService;
? _tcpService!
: _bleService;
/// Current connection mode
ConnectionMode _connectionMode = ConnectionMode.ble;
@@ -117,6 +117,8 @@ class ConnectionProvider with ChangeNotifier {
bool _noMoreMessages = false;
// Prevent overlapping/too-frequent sync requests
bool _isSyncingMessages = false;
// If MSG_WAITING arrives while a sync loop is active, queue one more pass.
bool _syncRequestedWhileBusy = false;
DateTime? _lastSyncNextRequestedAt;
static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
@@ -310,7 +312,14 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageWaiting = () {
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
syncAllMessages();
if (_isSyncingMessages) {
_syncRequestedWhileBusy = true;
debugPrint(
' ↪️ [Provider] Sync already running; queued follow-up sync',
);
return;
}
unawaited(syncAllMessages());
};
service.onLoginSuccess =
@@ -558,7 +567,9 @@ class ConnectionProvider with ChangeNotifier {
final success = await _tcpService!.connect(host, port);
if (!success) {
_deviceInfo = _deviceInfo.copyWith(connectionState: ConnectionState.error);
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
notifyListeners();
}
return success;
@@ -1169,7 +1180,10 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
await _activeService.sendChannelMessage(channelIdx: channelIdx, text: text);
await _activeService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
debugPrint('✅ [ConnectionProvider] BLE send completed');
debugPrint(
@@ -1655,6 +1669,7 @@ class ConnectionProvider with ChangeNotifier {
Future<int> syncAllMessages() async {
if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
return 0;
}
@@ -1664,83 +1679,94 @@ class ConnectionProvider with ChangeNotifier {
return 0;
}
int count = 0;
_noMoreMessages = false; // Reset flag
int totalCount = 0;
try {
_isSyncingMessages = true;
debugPrint('🔄 [Provider] Starting message sync loop...');
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
do {
_syncRequestedWhileBusy = false;
_noMoreMessages = false; // Reset flag per pass
int passCount = 0;
debugPrint('🔄 [Provider] Starting message sync loop...');
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
// Keep syncing until we get NoMoreMessages response
// The device will send ContactMsgRecv or ChannelMsgRecv responses
// until it sends NoMoreMessages
for (int i = 0; i < 100; i++) {
// Safety limit
// Check flag BEFORE sending (not after)
if (_noMoreMessages) {
debugPrint(
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $passCount requests',
);
break;
}
// Keep syncing until we get NoMoreMessages response
// The device will send ContactMsgRecv or ChannelMsgRecv responses
// until it sends NoMoreMessages
for (int i = 0; i < 100; i++) {
// Safety limit
// Check flag BEFORE sending (not after)
if (_noMoreMessages) {
debugPrint(
' [Provider] Message sync complete - NoMoreMessages flag set after $count requests',
'📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE',
);
break;
}
debugPrint(
'📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE',
);
// Create new completer for this request
_syncResponseCompleter = Completer<bool>();
// Create new completer for this request
_syncResponseCompleter = Completer<bool>();
// Respect the minimum interval between requests
final now = DateTime.now();
if (_lastSyncNextRequestedAt != null) {
final elapsed = now.difference(_lastSyncNextRequestedAt!);
if (elapsed < _minSyncNextInterval) {
final remaining = _minSyncNextInterval - elapsed;
await Future.delayed(remaining);
}
}
// Respect the minimum interval between requests
final now = DateTime.now();
if (_lastSyncNextRequestedAt != null) {
final elapsed = now.difference(_lastSyncNextRequestedAt!);
if (elapsed < _minSyncNextInterval) {
final remaining = _minSyncNextInterval - elapsed;
await Future.delayed(remaining);
await _activeService.syncNextMessage();
_lastSyncNextRequestedAt = DateTime.now();
passCount++;
totalCount++;
// Wait for response (true = message received, false = no more messages)
// Timeout after 2 seconds to prevent hanging
final hasMore = await _syncResponseCompleter!.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
debugPrint('⚠️ [Provider] Sync timeout - no response after 2s');
return false;
},
);
debugPrint(
' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages',
);
if (!hasMore) {
debugPrint(' ✅ No more messages available, stopping sync');
break;
}
}
await _activeService.syncNextMessage();
_lastSyncNextRequestedAt = DateTime.now();
count++;
// Wait for response (true = message received, false = no more messages)
// Timeout after 2 seconds to prevent hanging
final hasMore = await _syncResponseCompleter!.future.timeout(
const Duration(seconds: 2),
onTimeout: () {
debugPrint('⚠️ [Provider] Sync timeout - no response after 2s');
return false;
},
);
debugPrint(
' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages',
);
if (!hasMore) {
debugPrint(' ✅ No more messages available, stopping sync');
break;
if (!_noMoreMessages && passCount >= 100) {
debugPrint(
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
);
}
}
if (!_noMoreMessages && count >= 100) {
debugPrint(
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
);
}
if (_syncRequestedWhileBusy) {
debugPrint(
' [Provider] MSG_WAITING received during sync; running another pass',
);
}
} while (_syncRequestedWhileBusy && _activeService.isConnected);
debugPrint(
'🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages',
'🏁 [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages',
);
return count;
return totalCount;
} catch (e) {
debugPrint('❌ [Provider] Failed to sync messages: $e');
_error = 'Failed to sync messages: $e';
notifyListeners();
return count;
return totalCount;
} finally {
_isSyncingMessages = false;
_syncResponseCompleter = null;

View File

@@ -0,0 +1,102 @@
import 'package:flutter/foundation.dart';
import '../../models/contact.dart';
typedef RawPacketSender =
Future<void> Function({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
});
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
required Contact requester,
required List<T> fragments,
required int maxDirectPayloadHops,
required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
return false;
}
if (sendRawPacket == null) {
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
return false;
}
if (requester.outPathLen < 0) {
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
return false;
}
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
);
return false;
}
var servedCount = 0;
for (final fragment in fragments) {
final index = indexOf(fragment);
if (index < 0) {
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
continue;
}
if (requestedIndices != null && !requestedIndices.contains(index)) {
continue;
}
try {
final ackFuture = waitForFragmentAck?.call(
sessionId: sessionId,
index: index,
timeout: ackTimeout,
);
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: encodeBinary(fragment),
);
servedCount++;
if (ackFuture != null) {
final acked = await ackFuture;
if (!acked) {
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
return false;
}
}
} catch (e, st) {
debugPrint(
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
);
return false;
}
}
if (servedCount == 0) {
debugPrint(
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
);
return false;
}
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
return true;
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart';
/// Reassembly state for one incoming image session.
@@ -13,6 +14,8 @@ class ImageSession {
final int width;
final int height;
final List<ImagePacket?> fragments; // indexed by fragment.index
DateTime? firstFragmentAt;
DateTime? lastFragmentAt;
ImageSession({
required this.sessionId,
@@ -25,6 +28,21 @@ class ImageSession {
int get receivedCount => fragments.where((f) => f != null).length;
bool get isComplete => receivedCount == total;
Duration? estimateRemaining() {
if (isComplete) return Duration.zero;
if (firstFragmentAt == null || lastFragmentAt == null) return null;
if (receivedCount < 2) return null;
final elapsedMs = lastFragmentAt!
.difference(firstFragmentAt!)
.inMilliseconds;
if (elapsedMs <= 0) return null;
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
final remaining = total - receivedCount;
if (remaining <= 0) return Duration.zero;
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
}
/// Reassemble the complete image bytes, or null if any fragment is missing.
Uint8List? get imageBytes => reassembleImage(fragments);
}
@@ -51,6 +69,12 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() {
_restore();
@@ -62,6 +86,8 @@ class ImageProvider with ChangeNotifier {
bool isComplete(String sessionId) =>
_sessions[sessionId]?.isComplete ?? false;
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId];
@@ -94,7 +120,13 @@ class ImageProvider with ChangeNotifier {
final session = _sessions[fragment.sessionId]!;
if (fragment.index < session.total) {
final wasMissing = session.fragments[fragment.index] == null;
session.fragments[fragment.index] = fragment;
if (wasMissing) {
final now = DateTime.now();
session.firstFragmentAt ??= now;
session.lastFragmentAt = now;
}
}
final justComplete = session.isComplete;
@@ -106,19 +138,8 @@ class ImageProvider with ChangeNotifier {
/// Register envelope metadata for a session (called when IE1 is received
/// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) {
_sessions.putIfAbsent(
envelope.sessionId,
() => ImageSession(
sessionId: envelope.sessionId,
format: envelope.format,
total: envelope.total,
width: envelope.width,
height: envelope.height,
),
);
// Update dimensions if we created the session from a fragment (w/h = 0).
final session = _sessions[envelope.sessionId]!;
if (session.width == 0 || session.height == 0) {
final existing = _sessions[envelope.sessionId];
if (existing == null) {
_sessions[envelope.sessionId] = ImageSession(
sessionId: envelope.sessionId,
format: envelope.format,
@@ -126,12 +147,38 @@ class ImageProvider with ChangeNotifier {
width: envelope.width,
height: envelope.height,
);
// Copy existing fragments into the new session.
final old = _sessions[envelope.sessionId]!;
for (var i = 0; i < session.fragments.length && i < old.total; i++) {
old.fragments[i] = session.fragments[i];
unawaited(_persist());
notifyListeners();
return;
}
final needsMerge =
existing.width == 0 ||
existing.height == 0 ||
existing.total != envelope.total ||
existing.format != envelope.format;
if (!needsMerge) {
notifyListeners();
return;
}
final merged = ImageSession(
sessionId: envelope.sessionId,
format: envelope.format,
total: envelope.total,
width: envelope.width,
height: envelope.height,
);
merged.firstFragmentAt = existing.firstFragmentAt;
merged.lastFragmentAt = existing.lastFragmentAt;
for (final fragment in existing.fragments) {
if (fragment == null) continue;
if (fragment.index < merged.total) {
merged.fragments[fragment.index] = fragment;
}
}
_sessions[envelope.sessionId] = merged;
unawaited(_persist());
notifyListeners();
}
@@ -183,41 +230,18 @@ class ImageProvider with ChangeNotifier {
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
return false;
}
if (sendRawPacketCallback == null) {
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set');
return false;
}
if (requester.outPathLen < 0) {
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
return false;
}
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
for (final fragment in cached.fragments) {
if (requestedIndices != null &&
!requestedIndices.contains(fragment.index)) {
continue;
}
try {
await sendRawPacketCallback!(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: fragment.encodeBinary(),
);
} catch (e, st) {
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
return false;
}
}
debugPrint(
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
return serveCachedSessionFragments<ImagePacket>(
providerLabel: 'ImageProvider',
sessionId: sessionId,
requester: requester,
fragments: cached.fragments,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
return true;
}
// ── Persistence ──────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart';
@@ -13,6 +14,8 @@ class VoiceSession {
final VoicePacketMode mode;
final int total;
final List<VoicePacket?> packets; // indexed by packet.index
DateTime? firstPacketAt;
DateTime? lastPacketAt;
VoiceSession({
required this.sessionId,
@@ -23,6 +26,19 @@ class VoiceSession {
int get receivedCount => packets.where((p) => p != null).length;
bool get isComplete => receivedCount == total;
Duration? estimateRemaining() {
if (isComplete) return Duration.zero;
if (firstPacketAt == null || lastPacketAt == null) return null;
if (receivedCount < 2) return null;
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
if (elapsedMs <= 0) return null;
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
final remaining = total - receivedCount;
if (remaining <= 0) return Duration.zero;
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
}
/// Total estimated audio duration in seconds (sum of all received packets).
double get estimatedDurationSeconds {
var ms = 0;
@@ -54,6 +70,12 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -93,6 +115,8 @@ class VoiceProvider with ChangeNotifier {
bool hasOutgoingSession(String sessionId) =>
_outgoingSessions.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining();
List<int> missingPacketIndices(String sessionId) {
final session = _sessions[sessionId];
@@ -120,7 +144,13 @@ class VoiceProvider with ChangeNotifier {
final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) {
final wasMissing = session.packets[packet.index] == null;
session.packets[packet.index] = packet;
if (wasMissing) {
final now = DateTime.now();
session.firstPacketAt ??= now;
session.lastPacketAt = now;
}
}
final justComplete = session.isComplete;
@@ -152,42 +182,18 @@ class VoiceProvider with ChangeNotifier {
);
return false;
}
if (sendRawPacketCallback == null) {
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
return false;
}
if (requester.outPathLen < 0) {
debugPrint(
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
);
return false;
}
if (requester.outPathLen > maxDirectPayloadHops) {
debugPrint(
'⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
);
return false;
}
for (final packet in cached.packets) {
if (requestedIndices != null &&
!requestedIndices.contains(packet.index)) {
continue;
}
try {
await sendRawPacketCallback!(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
payload: packet.encodeBinary(),
);
} catch (e, st) {
debugPrint(
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
);
return false;
}
}
return true;
return serveCachedSessionFragments<VoicePacket>(
providerLabel: 'VoiceProvider',
sessionId: sessionId,
requester: requester,
fragments: cached.packets,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
}
// ── Playback ─────────────────────────────────────────────────────────────

View File

@@ -23,6 +23,8 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, map }
class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
final Function(Locale?) onLocaleChanged;
@@ -50,13 +52,30 @@ class _HomeScreenState extends State<HomeScreen>
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
List<_HomeTab> get _enabledTabs {
return [
_HomeTab.messages,
if (_isContactsEnabled) _HomeTab.contacts,
if (_isMapEnabled) _HomeTab.map,
];
}
_HomeTab get _currentTab {
final tabs = _enabledTabs;
final safeIndex = _currentIndex < tabs.length
? _currentIndex
: tabs.length - 1;
return tabs[safeIndex < 0 ? 0 : safeIndex];
}
@override
void initState() {
super.initState();
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadMapEnabledAndInitTabs();
_loadTabVisibilityAndInitTabs();
_loadRxTxPreference();
// Show permission dialog after the first frame if needed
@@ -67,58 +86,75 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadMapEnabledAndInitTabs() async {
Future<void> _loadTabVisibilityAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
if (!mounted) return;
if (_isMapEnabled != mapEnabled) {
_updateTabController(mapEnabled);
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
_updateTabController(
mapEnabled: mapEnabled,
contactsEnabled: contactsEnabled,
);
}
}
void _initTabController() {
final tabCount = _isMapEnabled ? 3 : 2;
_tabController = TabController(length: tabCount, vsync: this);
_tabController = TabController(length: _enabledTabs.length, vsync: this);
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
setState(() {
_currentIndex = _tabController.index;
// Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2)
if (_isMapEnabled && _currentIndex != 2) {
if (_currentTab != _HomeTab.map) {
_isMapFullscreen = false;
}
});
}
void _updateTabController(bool mapEnabled) {
if (_isMapEnabled == mapEnabled) return;
void _updateTabController({
required bool mapEnabled,
required bool contactsEnabled,
}) {
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
return;
}
// Save current index before rebuilding
final oldTabs = _enabledTabs;
final oldIndex = _tabController.index;
final oldTab = oldTabs[oldIndex];
// Remove old listener and dispose
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
final oldController = _tabController;
oldController.removeListener(_onTabChanged);
// Update state
_isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled;
final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab);
// Create new controller
final tabCount = mapEnabled ? 3 : 2;
_tabController = TabController(length: tabCount, vsync: this);
_tabController = TabController(length: newTabs.length, vsync: this);
_tabController.addListener(_onTabChanged);
// Restore index (clamp to valid range)
if (oldIndex < tabCount) {
_tabController.index = oldIndex;
_currentIndex = oldIndex;
} else {
_currentIndex = tabCount - 1;
}
_currentIndex = newIndex >= 0 ? newIndex : 0;
_tabController.index = _currentIndex;
setState(() {});
// Dispose old controller after widgets have rebound to the new controller.
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
}
void _navigateToTab(_HomeTab tab) {
final targetIndex = _enabledTabs.indexOf(tab);
if (targetIndex >= 0 && targetIndex != _tabController.index) {
_tabController.animateTo(targetIndex);
}
}
Future<void> _loadRxTxPreference() async {
@@ -276,18 +312,23 @@ class _HomeScreenState extends State<HomeScreen>
messagesProvider.setLocalizations(localizations);
}
// Check if map enabled setting changed and update tab controller
// Check if tab visibility settings changed and update tab controller
final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled) {
if (_isMapEnabled != appProvider.isMapEnabled ||
_isContactsEnabled != appProvider.isContactsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_updateTabController(appProvider.isMapEnabled);
_updateTabController(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
);
});
}
// Determine if we should hide the UI (only in fullscreen on map tab)
final shouldHideUI =
_isMapEnabled && _isMapFullscreen && _currentIndex == 2;
final enabledTabs = _enabledTabs;
final isMapTabActive = _currentTab == _HomeTab.map;
final shouldHideUI = _isMapEnabled && _isMapFullscreen && isMapTabActive;
final shouldShowTabBar = enabledTabs.length > 1;
return Scaffold(
appBar: shouldHideUI
@@ -374,29 +415,33 @@ class _HomeScreenState extends State<HomeScreen>
),
body: TabBarView(
controller: _tabController,
children: [
MessagesTab(
onNavigateToMap: _isMapEnabled
? () => _tabController.animateTo(2)
: null,
),
ContactsTab(
onNavigateToMap: _isMapEnabled
? () => _tabController.animateTo(2)
: null,
),
if (_isMapEnabled)
MapTab(
onFullscreenChanged: (isFullscreen) {
setState(() {
_isMapFullscreen = isFullscreen;
});
},
onNavigateToMessages: () => _tabController.animateTo(0),
),
],
children: enabledTabs.map((tab) {
switch (tab) {
case _HomeTab.messages:
return MessagesTab(
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
);
case _HomeTab.contacts:
return ContactsTab(
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
);
case _HomeTab.map:
return MapTab(
onFullscreenChanged: (isFullscreen) {
setState(() {
_isMapFullscreen = isFullscreen;
});
},
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
}
}).toList(),
),
bottomNavigationBar: shouldHideUI
bottomNavigationBar: shouldHideUI || !shouldShowTabBar
? null
: Consumer2<MessagesProvider, ContactsProvider>(
builder: (context, messagesProvider, contactsProvider, child) {
@@ -415,27 +460,31 @@ class _HomeScreenState extends State<HomeScreen>
),
child: TabBar(
controller: _tabController,
tabs: [
Tab(
icon: _buildTabIconWithBadge(
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
),
Tab(
icon: _buildTabIconWithBadge(
Icons.contacts,
newContactsCount,
),
text: AppLocalizations.of(context)!.contacts,
),
if (_isMapEnabled)
Tab(
icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
),
],
tabs: enabledTabs.map((tab) {
switch (tab) {
case _HomeTab.messages:
return Tab(
icon: _buildTabIconWithBadge(
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
);
case _HomeTab.contacts:
return Tab(
icon: _buildTabIconWithBadge(
Icons.contacts,
newContactsCount,
),
text: AppLocalizations.of(context)!.contacts,
);
case _HomeTab.map:
return Tab(
icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
);
}
}).toList(),
),
);
},

View File

@@ -27,6 +27,7 @@ import '../utils/toast_logger.dart';
import '../utils/key_comparison.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../utils/tictactoe_message_parser.dart';
import '../providers/image_provider.dart' as ip;
import '../services/image_codec_service.dart';
import '../services/image_preferences.dart';
@@ -428,6 +429,54 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
Future<void> _startTicTacToeGame() async {
if (!mounted) return;
if (_destinationType !=
MessageDestinationPreferences.destinationTypeContact ||
_selectedRecipient == null) {
ToastLogger.warning(
context,
'Tic-Tac-Toe works only in direct messages. Choose a contact first.',
);
return;
}
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
final contactsProvider = context.read<ContactsProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
if (devicePublicKey == null || devicePublicKey.length < 6) {
ToastLogger.error(context, 'Device key unavailable');
return;
}
final gameId = List.generate(
8,
(_) => math.Random.secure().nextInt(16).toRadixString(16),
).join();
final starterKey6 = devicePublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final startMessage = TicTacToeMessageParser.encodeStart(
gameId: gameId,
starterKey6: starterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
await _sendToRecipient(
startMessage,
connectionProvider,
messagesProvider,
contactsProvider,
);
}
// ── Image sending ───────────────────────────────────────────────────────────
Future<void> _pickAndSendImage({
@@ -1077,6 +1126,15 @@ class _MessagesTabState extends State<MessagesTab> {
_pickAndSendImage(source: ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'),
onTap: () {
Navigator.pop(sheetContext);
_startTicTacToeGame();
},
),
],
),
);

View File

@@ -621,6 +621,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _clearMessages() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Messages'),
content: const Text(
'This will permanently delete all stored messages. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
final messagesProvider = Provider.of<MessagesProvider>(
context,
listen: false,
);
messagesProvider.clearMessages();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All messages cleared'),
backgroundColor: Colors.orange,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -687,6 +727,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.contacts_outlined),
title: const Text('Disable Contacts'),
subtitle: const Text(
'Hide the contacts tab to simplify navigation',
),
value: !appProvider.isContactsEnabled,
onChanged: (value) async {
await appProvider.toggleContactsEnabled(!value);
},
),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
@@ -694,6 +747,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages,
),
const Divider(),
// Voice Settings Section

View File

@@ -243,11 +243,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane).
///
/// Text format:
/// IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
/// Example:
/// IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
class ImageEnvelope {
static const String prefix = 'IE1:';
static const String _prefix = 'IE2:';
final String sessionId; // 8 hex chars
final ImageFormat format;
@@ -268,38 +268,36 @@ class ImageEnvelope {
required this.sizeBytes,
required this.senderKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isEnvelope(String text) => text.startsWith(prefix);
static bool isEnvelope(String text) => text.startsWith(_prefix);
static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null;
final body = text.substring(prefix.length);
final body = text.substring(_prefix.length);
final parts = body.split(':');
if (parts.length != 9) return null;
if (parts.length != 8) return null;
try {
final sid = parts[0];
final fmtId = int.tryParse(parts[1]);
final total = int.tryParse(parts[2]);
final w = int.tryParse(parts[3]);
final h = int.tryParse(parts[4]);
final bytes = int.tryParse(parts[5]);
final sid = _decodeSessionId(parts[0]);
final fmtId = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true);
final w = _parseInt(parts[3], base36: true);
final h = _parseInt(parts[4], base36: true);
final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6];
final ts = int.tryParse(parts[7]);
final ver = int.tryParse(parts[8]);
final ts = _parseInt(parts[7], base36: true);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
if (sid == null) return null;
if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageEnvelope(
sessionId: sid.toLowerCase(),
sessionId: sid,
format: ImageFormat.fromId(fmtId),
total: total,
width: w,
@@ -307,7 +305,7 @@ class ImageEnvelope {
sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
@@ -315,17 +313,21 @@ class ImageEnvelope {
}
String encode() =>
'$prefix${sessionId.toLowerCase()}:${format.id}:$total:$width:$height:$sizeBytes:${senderKey6.toLowerCase()}:$timestampSec:$version';
'$_prefix${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
/// Direct request to fetch image fragments (control plane).
///
/// Text format:
/// IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
/// Example:
/// IR1:deadbeef:a:aabbccddeeff:1700000010:1
/// IR2:deadbeef:a:aabbccddeeff:s44wea
class ImageFetchRequest {
static const String prefix = 'IR1:';
static const String _prefix = 'IR2:';
static const int _binaryMagic = 0x69; // 'i'
final String sessionId;
final String want; // 'all' or 'missing'
@@ -340,51 +342,89 @@ class ImageFetchRequest {
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isRequest(String text) => text.startsWith(prefix);
static bool isRequest(String text) => text.startsWith(_prefix);
static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null;
final body = text.substring(prefix.length);
final body = text.substring(_prefix.length);
final parts = body.split(':');
if (parts.length != 5) return null;
if (parts.length != 4) return null;
try {
final sid = parts[0];
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]);
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) return null;
if (sid == null) return null;
final missingIndices = <int>[];
if (normalizedWant == 'missing') {
final encoded = wantToken.substring(2);
final encoded = wantToken.substring(1);
if (encoded.isEmpty) return null;
for (final raw in encoded.split(',')) {
final idx = int.tryParse(raw);
if (idx == null || idx < 0 || idx > 254) return null;
missingIndices.add(idx);
}
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
if (missingIndices.isEmpty) return null;
} else if (normalizedWant != 'all') {
return null;
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return ImageFetchRequest(
sessionId: sid.toLowerCase(),
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
}
}
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final flags = payload[5];
final requesterKey6 = payload
.sublist(6, 12)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
}
return ImageFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
);
} catch (_) {
return null;
@@ -393,10 +433,168 @@ class ImageFetchRequest {
String encode() {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm-${missingIndices.join(',')}'
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
final missing = useMissing
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = useMissing ? 0x01 : 0x00;
for (var i = 0; i < 6; i++) {
out[6 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
}
return out;
}
}
/// Per-fragment ACK for raw image payload packets.
///
/// Binary format:
/// [0x6a 'j'][sessionId:4B][index:1B]
class ImageFragmentAck {
static const int _binaryMagic = 0x6a; // 'j'
final String sessionId; // 8 hex chars
final int index; // 0..254
const ImageFragmentAck({required this.sessionId, required this.index});
static bool isImageFragmentAckBinary(Uint8List payload) =>
payload.length == 6 && payload[0] == _binaryMagic;
static ImageFragmentAck? tryParseBinary(Uint8List payload) {
if (!isImageFragmentAckBinary(payload)) return null;
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final idx = payload[5];
return ImageFragmentAck(sessionId: sid, index: idx);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (index < 0 || index > 254) {
throw ArgumentError.value(index, 'index', 'Expected 0..254');
}
final out = Uint8List(6);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = index;
return out;
}
}
int? _parseInt(String token, {required bool base36}) =>
int.tryParse(token, radix: base36 ? 36 : 10);
String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
}
String? _decodeSessionId(String token) {
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
return value.toRadixString(16).padLeft(8, '0');
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
var start = sorted.first;
var prev = sorted.first;
for (var i = 1; i < sorted.length; i++) {
final curr = sorted[i];
if (curr == prev + 1) {
prev = curr;
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
return chunks.join('.');
}
List<int> _decodeMissingIndicesCompact(String encoded) {
final out = <int>[];
for (final token in encoded.split('.')) {
if (token.isEmpty) continue;
if (!token.contains('-')) {
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 254) return const [];
out.add(value);
continue;
}
final parts = token.split('-');
if (parts.length != 2) return const [];
final start = int.tryParse(parts[0], radix: 36);
final end = int.tryParse(parts[1], radix: 36);
if (start == null || end == null || start < 0 || end > 254 || start > end) {
return const [];
}
for (var i = start; i <= end; i++) {
out.add(i);
}
}
return out;
}
/// Fragment the compressed image bytes into [ImagePacket] list.

View File

@@ -0,0 +1,163 @@
enum TicTacToeEventType { start, move }
class TicTacToeEvent {
final TicTacToeEventType type;
final String gameId;
final String playerKey6;
final int? cell;
final int timestampSec;
const TicTacToeEvent({
required this.type,
required this.gameId,
required this.playerKey6,
this.cell,
required this.timestampSec,
});
}
class TicTacToeMessageParser {
static const String _prefix = 'TTT1:';
static bool isTicTacToe(String text) => text.startsWith(_prefix);
static TicTacToeEvent? tryParse(String text) {
if (!isTicTacToe(text)) return null;
final body = text.substring(_prefix.length);
final parts = body.split(':');
if (parts.length < 4) return null;
final action = parts[0];
final gameId = parts[1].toLowerCase();
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null;
if (action == 'S' && parts.length == 4) {
final starterKey6 = parts[2].toLowerCase();
final ts = int.tryParse(parts[3]);
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null;
if (ts == null || ts <= 0) return null;
return TicTacToeEvent(
type: TicTacToeEventType.start,
gameId: gameId,
playerKey6: starterKey6,
timestampSec: ts,
);
}
if (action == 'M' && parts.length == 5) {
final cell = int.tryParse(parts[2]);
final playerKey6 = parts[3].toLowerCase();
final ts = int.tryParse(parts[4]);
if (cell == null || cell < 0 || cell > 8) return null;
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null;
if (ts == null || ts <= 0) return null;
return TicTacToeEvent(
type: TicTacToeEventType.move,
gameId: gameId,
playerKey6: playerKey6,
cell: cell,
timestampSec: ts,
);
}
return null;
}
static String encodeStart({
required String gameId,
required String starterKey6,
required int timestampSec,
}) {
return '$_prefix'
'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec';
}
static String encodeMove({
required String gameId,
required int cell,
required String playerKey6,
required int timestampSec,
}) {
return '$_prefix'
'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec';
}
}
class TicTacToeGameState {
final String gameId;
final String xPlayerKey6;
final String oPlayerKey6;
final List<String?> board; // 'X' / 'O' / null
final String nextSymbol;
final String? winnerSymbol;
const TicTacToeGameState({
required this.gameId,
required this.xPlayerKey6,
required this.oPlayerKey6,
required this.board,
required this.nextSymbol,
this.winnerSymbol,
});
bool get isDraw =>
winnerSymbol == null && board.every((cell) => cell != null);
bool get isFinished => winnerSymbol != null || isDraw;
}
TicTacToeGameState buildTicTacToeState({
required String gameId,
required String xPlayerKey6,
required String oPlayerKey6,
required List<TicTacToeEvent> events,
}) {
final board = List<String?>.filled(9, null);
var next = 'X';
String? winner;
final sorted = [...events]
..sort((a, b) => a.timestampSec.compareTo(b.timestampSec));
for (final event in sorted) {
if (event.type != TicTacToeEventType.move) continue;
if (winner != null) break;
final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6;
final cell = event.cell;
if (cell == null) continue;
if (event.playerKey6 != expectedKey) continue;
if (board[cell] != null) continue;
board[cell] = next;
winner = _computeWinner(board);
next = next == 'X' ? 'O' : 'X';
}
return TicTacToeGameState(
gameId: gameId,
xPlayerKey6: xPlayerKey6,
oPlayerKey6: oPlayerKey6,
board: board,
nextSymbol: next,
winnerSymbol: winner,
);
}
String? _computeWinner(List<String?> board) {
const lines = <List<int>>[
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
for (final line in lines) {
final a = board[line[0]];
if (a == null) continue;
if (a == board[line[1]] && a == board[line[2]]) return a;
}
return null;
}

View File

@@ -0,0 +1,157 @@
import 'dart:typed_data';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
class TransmissionTargetResolution {
final Contact? target;
final TransmissionTargetFailure? failure;
final int maxHops;
const TransmissionTargetResolution({
required this.target,
required this.failure,
required this.maxHops,
});
int get hops => target?.outPathLen ?? -1;
bool get isValid => target != null && failure == null;
}
class TransmissionTargetResolver {
const TransmissionTargetResolver._();
static Contact? resolveLocalTarget({
required ContactsProvider contactsProvider,
required bool isSentByMe,
Uint8List? recipientPublicKey,
Uint8List? senderPublicKeyPrefix,
String? senderKey6FromEnvelope,
String? senderName,
}) {
if (isSentByMe) {
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
if (recipient != null) return recipient;
}
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
if (byEnvelope != null) return byEnvelope;
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
if (byPrefix != null) return byPrefix;
return _findByName(contactsProvider, senderName);
}
static Future<TransmissionTargetResolution> resolveFetchTarget({
required ContactsProvider contactsProvider,
required Future<void> Function() refreshContacts,
required bool isSentByMe,
Uint8List? recipientPublicKey,
Uint8List? senderPublicKeyPrefix,
String? senderKey6FromEnvelope,
String? senderName,
required int maxFetchHops,
}) async {
var target = resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: isSentByMe,
recipientPublicKey: recipientPublicKey,
senderPublicKeyPrefix: senderPublicKeyPrefix,
senderKey6FromEnvelope: senderKey6FromEnvelope,
senderName: senderName,
);
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
await refreshContacts();
target = resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: isSentByMe,
recipientPublicKey: recipientPublicKey,
senderPublicKeyPrefix: senderPublicKeyPrefix,
senderKey6FromEnvelope: senderKey6FromEnvelope,
senderName: senderName,
);
}
if (target == null) {
return TransmissionTargetResolution(
target: null,
failure: TransmissionTargetFailure.unknownContact,
maxHops: maxFetchHops,
);
}
if (target.outPathLen < 0) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.unknownRoute,
maxHops: maxFetchHops,
);
}
if (target.outPathLen > maxFetchHops) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.tooFar,
maxHops: maxFetchHops,
);
}
return TransmissionTargetResolution(
target: target,
failure: null,
maxHops: maxFetchHops,
);
}
static Contact? _findByRecipientKey(
ContactsProvider contactsProvider,
Uint8List? recipientKey,
) {
if (recipientKey == null || recipientKey.isEmpty) return null;
final byKey = contactsProvider.findContactByKey(recipientKey);
if (byKey != null) return byKey;
if (recipientKey.length >= 6) {
return contactsProvider.findContactByPrefix(
Uint8List.fromList(recipientKey.sublist(0, 6)),
);
}
return null;
}
static Contact? _findByEnvelopeKey6(
ContactsProvider contactsProvider,
String? senderKey6FromEnvelope,
) {
if (senderKey6FromEnvelope == null || senderKey6FromEnvelope.isEmpty) {
return null;
}
return contactsProvider.findContactByPrefixHex(senderKey6FromEnvelope);
}
static Contact? _findByPrefix(
ContactsProvider contactsProvider,
Uint8List? senderPublicKeyPrefix,
) {
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
return null;
}
return contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPublicKeyPrefix.sublist(0, 6)),
);
}
static Contact? _findByName(
ContactsProvider contactsProvider,
String? senderName,
) {
final normalized = senderName?.trim();
if (normalized == null || normalized.isEmpty) return null;
for (final contact in contactsProvider.contacts) {
if (contact.advName.trim().toLowerCase() == normalized.toLowerCase()) {
return contact;
}
}
return null;
}
}

View File

@@ -181,11 +181,11 @@ class VoicePacket {
/// Lightweight public/direct message envelope advertising voice availability.
///
/// Text format:
/// VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
/// Example:
/// VE1:00112233:1:4:3200:aabbccddeeff:1234567890:1
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
class VoiceEnvelope {
static const String _prefix = 'VE1:';
static const String _prefix = 'VE2:';
final String sessionId;
final VoicePacketMode mode;
@@ -202,7 +202,7 @@ class VoiceEnvelope {
required this.durationMs,
required this.senderKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -210,43 +210,41 @@ class VoiceEnvelope {
static VoiceEnvelope? tryParseText(String text) {
if (!isVoiceEnvelopeText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
return _tryParse(body);
}
static VoiceEnvelope? _tryParseCompact(String body) {
static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 7) return null;
if (parts.length != 6) return null;
try {
final sid = parts[0];
final mode = int.tryParse(parts[1]);
final total = int.tryParse(parts[2]);
final durMs = int.tryParse(parts[3]);
final sid = _decodeSessionId(parts[0]);
final mode = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true);
final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4];
final ts = int.tryParse(parts[5]);
final ver = int.tryParse(parts[6]);
final ts = _parseInt(parts[5], base36: true);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
if (sid == null) {
return null;
}
if (mode == null || mode < 0 || mode >= VoicePacketMode.values.length) {
return null;
}
if (total == null || total < 1 || total > 255) return null;
if (durMs == null || durMs < 0 || durMs > 10 * 60 * 1000) return null;
if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceEnvelope(
sessionId: sid.toLowerCase(),
sessionId: sid,
mode: VoicePacketMode.fromId(mode),
total: total,
durationMs: durMs,
durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
@@ -254,7 +252,8 @@ class VoiceEnvelope {
}
String encodeText() {
return '$_prefix${sessionId.toLowerCase()}:${mode.id}:$total:$durationMs:${senderKey6.toLowerCase()}:$timestampSec:$version';
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
}
@@ -415,11 +414,12 @@ int _resolveBandwidthHz(int? rawBw) {
/// Direct control-plane request to fetch voice packets for a session.
///
/// Text format:
/// VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
/// Example:
/// VR1:00112233:a:aabbccddeeff:1234567890:1
/// VR2:00112233:a:aabbccddeeff:kf12oi
class VoiceFetchRequest {
static const String _prefix = 'VR1:';
static const String _prefix = 'VR2:';
static const int _binaryMagic = 0x72; // 'r'
final String sessionId;
final String want;
@@ -434,42 +434,82 @@ class VoiceFetchRequest {
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 1,
this.version = 2,
});
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
static bool isVoiceFetchRequestText(String text) =>
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static VoiceFetchRequest? tryParseText(String text) {
if (!isVoiceFetchRequestText(text)) return null;
final body = text.substring(_prefix.length);
return _tryParseCompact(body);
return _tryParse(body);
}
static VoiceFetchRequest? _tryParseCompact(String body) {
final parts = body.split(':');
if (parts.length != 5) return null;
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
if (!isVoiceFetchRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
try {
final sid = parts[0];
final sidBytes = payload.sublist(1, 5);
final sid = sidBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final flags = payload[5];
final requesterKey6 = payload
.sublist(6, 12)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
}
return VoiceFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
);
} catch (_) {
return null;
}
}
static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 4) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = int.tryParse(parts[3]);
final ver = int.tryParse(parts[4]);
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: (wantToken.startsWith('m-') ? 'missing' : wantToken);
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sid)) {
if (sid == null) {
return null;
}
final missingIndices = <int>[];
if (normalizedWant == 'missing') {
final encoded = wantToken.substring(2);
final encoded = wantToken.substring(1);
if (encoded.isEmpty) return null;
for (final raw in encoded.split(',')) {
final idx = int.tryParse(raw);
if (idx == null || idx < 0 || idx > 254) return null;
missingIndices.add(idx);
}
missingIndices.addAll(_decodeMissingIndicesCompact(encoded));
if (missingIndices.isEmpty) return null;
} else if (normalizedWant != 'all') {
return null;
@@ -478,15 +518,14 @@ class VoiceFetchRequest {
return null;
}
if (ts == null || ts <= 0) return null;
if (ver == null || ver != 1) return null;
return VoiceFetchRequest(
sessionId: sid.toLowerCase(),
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: ver,
version: 2,
);
} catch (_) {
return null;
@@ -495,10 +534,168 @@ class VoiceFetchRequest {
String encodeText() {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm-${missingIndices.join(',')}'
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${sessionId.toLowerCase()}:$wantToken:${requesterKey6.toLowerCase()}:$timestampSec:$version';
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final useMissing = want == 'missing' && missingIndices.isNotEmpty;
final missing = useMissing
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = useMissing ? 0x01 : 0x00;
for (var i = 0; i < 6; i++) {
out[6 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
}
return out;
}
}
/// Per-fragment ACK for raw voice payload packets.
///
/// Binary format:
/// [0x76 'v'][sessionId:4B][index:1B]
class VoiceFragmentAck {
static const int _binaryMagic = 0x76; // 'v'
final String sessionId; // 8 hex chars
final int index; // 0..254
const VoiceFragmentAck({required this.sessionId, required this.index});
static bool isVoiceFragmentAckBinary(Uint8List payload) =>
payload.length == 6 && payload[0] == _binaryMagic;
static VoiceFragmentAck? tryParseBinary(Uint8List payload) {
if (!isVoiceFragmentAckBinary(payload)) return null;
try {
final sid = payload
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final idx = payload[5];
return VoiceFragmentAck(sessionId: sid, index: idx);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
if (index < 0 || index > 254) {
throw ArgumentError.value(index, 'index', 'Expected 0..254');
}
final out = Uint8List(6);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
}
out[5] = index;
return out;
}
}
int? _parseInt(String token, {required bool base36}) =>
int.tryParse(token, radix: base36 ? 36 : 10);
String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
}
String? _decodeSessionId(String token) {
if (!RegExp(r'^[0-9a-z]{1,7}$').hasMatch(token)) return null;
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 0xFFFFFFFF) return null;
return value.toRadixString(16).padLeft(8, '0');
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
var start = sorted.first;
var prev = sorted.first;
for (var i = 1; i < sorted.length; i++) {
final curr = sorted[i];
if (curr == prev + 1) {
prev = curr;
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
);
return chunks.join('.');
}
List<int> _decodeMissingIndicesCompact(String encoded) {
final out = <int>[];
for (final token in encoded.split('.')) {
if (token.isEmpty) continue;
if (!token.contains('-')) {
final value = int.tryParse(token, radix: 36);
if (value == null || value < 0 || value > 254) return const [];
out.add(value);
continue;
}
final parts = token.split('-');
if (parts.length != 2) return const [];
final start = int.tryParse(parts[0], radix: 36);
final end = int.tryParse(parts[1], radix: 36);
if (start == null || end == null || start < 0 || end > 254 || start > end) {
return const [];
}
for (var i = start; i <= end; i++) {
out.add(i);
}
}
return out;
}
/// Builds a compact visual waveform from real voice packet bytes.

View File

@@ -3,18 +3,18 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../utils/image_message_parser.dart';
import '../../utils/transmission_target_resolver.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a received or sent image.
///
/// On first render the image is not yet fetched (only the IE1 envelope is
/// known). The user taps the thumbnail placeholder → IR1 fetch request is
/// On first render the image is not yet fetched (only the IE2 envelope is
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
/// sent → binary fragments stream in → bubble rebuilds with the full image.
class ImageMessageBubble extends StatefulWidget {
final Message message;
@@ -58,8 +58,24 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
final session = imageProvider.session(envelope.sessionId);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId,
);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -95,6 +111,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
),
),
const SizedBox(height: 4),
@@ -106,12 +123,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received,
total: total,
envelope: envelope,
pathLen: widget.message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
error: _errorText,
isSentByMe: widget.isSentByMe,
eta: eta,
pathLen: effectivePathLen,
),
style: TextStyle(
fontSize: 11,
@@ -139,6 +157,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int? radioBw,
required int? radioSf,
required int? radioCr,
required int pathLen,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
@@ -180,7 +199,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
pathLen: pathLen,
),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70,
@@ -201,34 +220,43 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
var sender = _resolveSender(envelope);
if (sender == null) {
final conn = context.read<ConnectionProvider>();
await conn.getContacts();
if (!mounted) return;
sender = _resolveSender(envelope);
}
if (sender == null) {
final conn = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (sender.outPathLen < 0) {
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (sender.outPathLen > _maxFetchHops) {
if (resolution.failure == TransmissionTargetFailure.tooFar) {
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.',
@@ -236,7 +264,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
setState(() => _errorText = null);
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
@@ -275,26 +302,33 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_errorText = null;
});
final sent = await conn.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encode(),
contact: sender,
);
if (!sent && mounted) {
setState(() {
_isRequesting = false;
_errorText = 'Image unavailable right now';
});
final payload = request.encodeBinary();
try {
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: payload,
);
} catch (_) {
if (mounted) {
_showToast('Image fetch failed to send request');
setState(() {
_isRequesting = false;
_errorText = 'Image unavailable right now';
});
}
return;
}
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -306,38 +340,16 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (mounted &&
_isRequesting &&
!imageProvider.isComplete(envelope.sessionId)) {
setState(() => _isRequesting = false);
_showToast('Image fetch timed out');
setState(() {
_isRequesting = false;
_errorText = 'Image fetch timed out';
});
}
},
);
}
Contact? _resolveSender(ImageEnvelope envelope) {
final contactsProvider = context.read<ContactsProvider>();
final senderPrefix = widget.message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
final c = contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPrefix.sublist(0, 6)),
);
if (c != null) return c;
}
final contact = contactsProvider.findContactByPrefixHex(
envelope.senderKey6,
);
if (contact != null) return contact;
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final c in contactsProvider.contacts) {
if (c.advName.trim().toLowerCase() == senderName.toLowerCase()) {
return c;
}
}
}
return null;
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -347,6 +359,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -374,6 +387,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int? radioCr,
required String? error,
required bool isSentByMe,
required Duration? eta,
}) {
final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total,
@@ -386,7 +400,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
if (error != null) return error;
if (isRequesting) return '📥 Loading… $received/$total · $txEstimateLabel';
if (isRequesting) {
final etaLabel = _formatEta(eta);
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
}
if (isComplete) {
final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
@@ -404,6 +421,14 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return '~${minutes}m ${seconds}s tx';
}
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>(
context: context,

View File

@@ -22,10 +22,12 @@ import '../../utils/sar_message_parser.dart';
import '../../utils/key_comparison.dart';
import '../../utils/voice_message_parser.dart';
import '../../utils/image_message_parser.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
import 'tictactoe_message_bubble.dart';
import 'message_trace_sheet.dart';
/// Reusable message bubble widget that displays messages with various types:
@@ -1680,6 +1682,13 @@ class _MessageBubbleState extends State<MessageBubble> {
}
final message = widget.message;
final ticTacToeEvent = message.isContactMessage
? TicTacToeMessageParser.tryParse(message.text)
: null;
if (ticTacToeEvent?.type == TicTacToeEventType.move) {
// Hide move control packets from chat; the game bubble updates itself.
return const SizedBox.shrink();
}
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
@@ -2180,6 +2189,10 @@ class _MessageBubbleState extends State<MessageBubble> {
else if (ImageEnvelope.isEnvelope(message.text) &&
!widget.isCompact)
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
// Tic-Tac-Toe control message content
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
!widget.isCompact)
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),

View File

@@ -0,0 +1,297 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../utils/toast_logger.dart';
class TicTacToeMessageBubble extends StatelessWidget {
final Message message;
final bool isSentByMe;
const TicTacToeMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) {
return const SizedBox.shrink();
}
final conn = context.watch<ConnectionProvider>();
final contacts = context.read<ContactsProvider>();
final messages = context.watch<MessagesProvider>().messages;
final selfKey = conn.deviceInfo.publicKey;
if (selfKey == null || selfKey.length < 6) {
return const Text('Tic-Tac-Toe unavailable');
}
final selfKey6 = _key6Hex(selfKey);
final opponent = _resolveOpponentContact(
message: message,
contactsProvider: contacts,
isSentByMe: isSentByMe,
);
if (opponent == null) {
return const Text('Tic-Tac-Toe: opponent unknown');
}
final opponentKey6 = _key6Hex(opponent.publicKey);
final gameEvents = <TicTacToeEvent>[];
TicTacToeEvent? start;
for (final m in messages) {
if (!m.isContactMessage) continue;
final parsed = TicTacToeMessageParser.tryParse(m.text);
if (parsed == null || parsed.gameId != event.gameId) continue;
if (!_isSameDmThread(
message: m,
selfKey: selfKey,
opponentKey6: opponentKey6,
)) {
continue;
}
if (parsed.type == TicTacToeEventType.start) {
start ??= parsed;
} else {
gameEvents.add(parsed);
}
}
start ??= event.type == TicTacToeEventType.start ? event : null;
if (start == null) {
return const Text('Tic-Tac-Toe: waiting for start');
}
final xPlayer = start.playerKey6;
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
final state = buildTicTacToeState(
gameId: event.gameId,
xPlayerKey6: xPlayer,
oPlayerKey6: oPlayer,
events: gameEvents,
);
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
final titleColor = isSentByMe
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final statusColor = isSentByMe
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
: colorScheme.onSurface.withValues(alpha: 0.85);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 230),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
),
const SizedBox(height: 8),
_BoardGrid(
board: state.board,
enabled: isMyTurn,
isSentByMe: isSentByMe,
onTapCell: (idx) => _onCellTap(
context: context,
idx: idx,
state: state,
selfKey6: selfKey6,
opponent: opponent,
connectionProvider: conn,
),
),
const SizedBox(height: 8),
Text(
_statusText(state: state, mySymbol: mySymbol),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: statusColor),
),
],
),
);
}
Future<void> _onCellTap({
required BuildContext context,
required int idx,
required TicTacToeGameState state,
required String selfKey6,
required Contact opponent,
required ConnectionProvider connectionProvider,
}) async {
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(context, 'Not connected to device');
return;
}
final messagesProvider = context.read<MessagesProvider>();
final text = TicTacToeMessageParser.encodeMove(
gameId: state.gameId,
cell: idx,
playerKey6: selfKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
.sublist(0, 6);
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey,
);
messagesProvider.addSentMessage(sentMessage);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey,
text: text,
messageId: messageId,
contact: opponent,
);
if (!sent) {
messagesProvider.markMessageFailed(messageId);
if (!context.mounted) return;
ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move');
}
}
static String _statusText({
required TicTacToeGameState state,
required String mySymbol,
}) {
if (state.winnerSymbol != null) {
return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won';
}
if (state.isDraw) return 'Draw';
return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn';
}
static String _key6Hex(Uint8List key) => key
.sublist(0, math.min(6, key.length))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('')
.toLowerCase();
static Contact? _resolveOpponentContact({
required Message message,
required ContactsProvider contactsProvider,
required bool isSentByMe,
}) {
if (isSentByMe && message.recipientPublicKey != null) {
return contactsProvider.findContactByKey(message.recipientPublicKey!);
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return null;
return contactsProvider.findContactByPrefix(
Uint8List.fromList(sender.sublist(0, 6)),
);
}
static bool _isSameDmThread({
required Message message,
required Uint8List selfKey,
required String opponentKey6,
}) {
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
if (isOwn) {
final recipient = message.recipientPublicKey;
if (recipient == null || recipient.length < 6) return false;
return _key6Hex(recipient) == opponentKey6;
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return false;
return _key6Hex(sender) == opponentKey6;
}
}
class _BoardGrid extends StatelessWidget {
final List<String?> board;
final bool enabled;
final bool isSentByMe;
final ValueChanged<int> onTapCell;
const _BoardGrid({
required this.board,
required this.enabled,
required this.isSentByMe,
required this.onTapCell,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cellBackground = isSentByMe
? colorScheme.primaryContainer.withValues(alpha: 0.35)
: colorScheme.surface;
final cellBorder = isSentByMe
? colorScheme.primary.withValues(alpha: 0.45)
: colorScheme.outline.withValues(alpha: 0.35);
return SizedBox(
width: 180,
height: 180,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: 9,
itemBuilder: (context, idx) {
final value = board[idx];
return InkWell(
onTap: enabled && value == null ? () => onTapCell(idx) : null,
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: cellBackground,
border: Border.all(color: cellBorder),
),
child: Text(
value ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: value == 'X'
? colorScheme.primary
: value == 'O'
? colorScheme.tertiary
: null,
),
),
),
);
},
),
);
}
}

View File

@@ -1,13 +1,12 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/transmission_target_resolver.dart';
import '../../utils/voice_message_parser.dart';
import 'transfer_timeout.dart';
@@ -55,8 +54,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final contactsProvider = context.read<ContactsProvider>();
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen =
sender != null && sender.outPathLen >= 0
? sender.outPathLen
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
@@ -96,12 +108,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
session: session,
envelope: envelope,
messageText: widget.message.text,
pathLen: widget.message.pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
return Row(
mainAxisSize: MainAxisSize.min,
@@ -122,7 +135,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: widget.message.pathLen,
pathLen: effectivePathLen,
);
},
borderRadius: BorderRadius.circular(24),
@@ -175,6 +188,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
requestingLabel: AppLocalizations.of(
context,
)!.requestingVoice,
eta: eta,
),
style: TextStyle(
fontSize: 11,
@@ -200,34 +214,43 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
var sender = _resolveSenderContact();
if (sender == null) {
final connectionProvider = context.read<ConnectionProvider>();
await connectionProvider.getContacts();
if (!mounted) return;
sender = _resolveSenderContact();
}
if (sender == null) {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (sender.outPathLen < 0) {
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (sender.outPathLen > _maxFetchHops) {
if (resolution.failure == TransmissionTargetFailure.tooFar) {
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${sender.outPathLen} hops, max $_maxFetchHops).',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
_showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.',
@@ -239,7 +262,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>();
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
await _showBlockingAlert(
@@ -257,7 +279,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
version: 2,
);
setState(() {
@@ -266,23 +288,25 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_errorText = null;
});
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: sender.publicKey,
text: request.encodeText(),
contact: sender,
);
if (!sent) {
try {
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
payload: request.encodeBinary(),
);
} catch (_) {
_setUnavailable();
return;
}
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
pathLen: pathLen,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -301,6 +325,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
void _setUnavailable() {
if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
setState(() {
_isRequesting = false;
_autoPlayWhenReady = false;
@@ -308,36 +333,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
});
}
Contact? _resolveSenderContact() {
final contactsProvider = context.read<ContactsProvider>();
final senderPrefix = widget.message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
final contact = contactsProvider.findContactByPrefix(
Uint8List.fromList(senderPrefix.sublist(0, 6)),
);
if (contact != null) return contact;
}
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
if (envelope != null) {
final contact = contactsProvider.findContactByPrefixHex(
envelope.senderKey6,
);
if (contact != null) return contact;
}
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final contact in contactsProvider.contacts) {
if (contact.advName.trim().toLowerCase() == senderName.toLowerCase()) {
return contact;
}
}
}
return null;
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -347,6 +342,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -378,11 +374,12 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
required bool isRequesting,
required String? errorText,
required String requestingLabel,
required Duration? eta,
}) {
if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
return '$requestingLabel$progress · $txEstimateLabel';
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
@@ -462,6 +459,14 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final seconds = value.inSeconds % 60;
return '~${minutes}m ${seconds}s tx';
}
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
}
/// Voice waveform rendered as a row of bars.

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0305.1+7
version: 2026.0305.2+8
environment:
sdk: ^3.9.2

View File

@@ -0,0 +1,145 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/helpers/raw_session_retransmit.dart';
class _Fragment {
final int index;
final Uint8List payload;
_Fragment(this.index, this.payload);
}
Contact _buildContact({required int outPathLen}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: outPathLen,
outPath: Uint8List.fromList(List<int>.generate(8, (i) => i + 1)),
advName: 'Requester',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('serveCachedSessionFragments', () {
test('returns false when sender callback is missing', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket: null,
);
expect(ok, isFalse);
});
test('sends only requested indices and waits for ack', () async {
final sent = <Uint8List>[];
final waited = <int>[];
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([10])),
_Fragment(1, Uint8List.fromList([20])),
_Fragment(2, Uint8List.fromList([30])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {
sent.add(payload);
},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
waited.add(index);
return true;
},
requestedIndices: {1, 2},
);
expect(ok, isTrue);
expect(sent.length, equals(2));
expect(sent[0], equals(Uint8List.fromList([20])));
expect(sent[1], equals(Uint8List.fromList([30])));
expect(waited, equals([1, 2]));
});
test('fails when ack does not arrive', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
waitForFragmentAck:
({
required sessionId,
required index,
timeout = const Duration(seconds: 8),
}) async {
return false;
},
);
expect(ok, isFalse);
});
test('fails when no requested index matches cached fragments', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: 1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
requestedIndices: {99},
);
expect(ok, isFalse);
});
});
}

View File

@@ -8,7 +8,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessagesProvider voice detection', () {
test('marks VE1 envelope messages as voice', () {
test('marks VE2 envelope messages as voice', () {
final provider = MessagesProvider();
final envelope = VoiceEnvelope(
sessionId: 'deafbead',

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
void main() {
group('ImageEnvelope', () {
test('encodes and parses IE2 with compressed session id', () {
final env = ImageEnvelope(
sessionId: '0000000a',
format: ImageFormat.avif,
total: 14,
width: 256,
height: 171,
sizeBytes: 2100,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final text = env.encode();
expect(text.startsWith('IE2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageEnvelope.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.format, equals(ImageFormat.avif));
expect(parsed.total, equals(14));
expect(parsed.width, equals(256));
expect(parsed.height, equals(171));
expect(parsed.sizeBytes, equals(2100));
expect(parsed.senderKey6, equals('aabbccddeeff'));
expect(parsed.version, equals(2));
});
test('rejects IE1 legacy prefix', () {
const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1';
expect(ImageEnvelope.tryParse(legacy), isNull);
});
});
group('ImageFetchRequest', () {
test('encodes and parses IR2 with compressed sid', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encode();
expect(text.startsWith('IR2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
test('encodes and parses compact missing index ranges', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
want: 'missing',
missingIndices: const [0, 1, 2, 5, 6, 8],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encode();
expect(text, contains(':m0-2.5-6.8:'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8]));
});
test('rejects IR1 legacy prefix', () {
const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(ImageFetchRequest.tryParse(legacy), isNull);
});
test('encodes and parses binary fetch request', () {
final req = ImageFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [0, 2, 5],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final payload = req.encodeBinary();
expect(ImageFetchRequest.isRequestBinary(payload), isTrue);
final parsed = ImageFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 2, 5]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
});
group('ImageFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = ImageFragmentAck(sessionId: '01020304', index: 9);
final payload = ack.encodeBinary();
expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue);
final parsed = ImageFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(9));
});
});
}

View File

@@ -6,74 +6,122 @@ void main() {
group('VoiceEnvelope', () {
test('encodes and parses valid envelope', () {
final env = VoiceEnvelope(
sessionId: 'deadbeef',
sessionId: '0000000a',
mode: VoicePacketMode.mode1200,
total: 4,
durationMs: 3200,
durationMs: 3000,
senderKey6: 'aabbccddeeff',
timestampSec: 1700000000,
);
final text = env.encodeText();
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
expect(text.startsWith('VE2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceEnvelope.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('deadbeef'));
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.mode, equals(VoicePacketMode.mode1200));
expect(parsed.total, equals(4));
expect(parsed.durationMs, equals(3200));
expect(parsed.durationMs, equals(3000));
expect(parsed.senderKey6, equals('aabbccddeeff'));
expect(parsed.version, equals(1));
expect(parsed.version, equals(2));
});
test('rejects invalid envelope payload', () {
final text = 'VE1:nothex:1:2:1000:aabbccddeeff:1700000000:1';
final text = 'VE2:bad_sid:1:2:1000:aabbccddeeff:s44we8';
expect(VoiceEnvelope.tryParseText(text), isNull);
});
test('rejects legacy v1 envelope prefix', () {
const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1';
expect(VoiceEnvelope.tryParseText(legacy), isNull);
});
});
group('VoiceFetchRequest', () {
test('encodes and parses valid request', () {
final req = VoiceFetchRequest(
sessionId: '00112233',
sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encodeText();
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
expect(text.startsWith('VR2:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('00112233'));
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(1));
expect(parsed.version, equals(2));
});
test('rejects invalid request payload', () {
expect(
VoiceFetchRequest.tryParseText(
'VR1:00112233:chunk:ffeeddccbbaa:1700000001:1',
'VR2:a:chunk:ffeeddccbbaa:s44we9',
),
isNull,
);
});
test('rejects legacy v1 request prefix', () {
const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(VoiceFetchRequest.tryParseText(legacy), isNull);
});
test('encodes and parses missing-packet request', () {
final req = VoiceFetchRequest(
sessionId: '00112233',
sessionId: '0000000a',
want: 'missing',
missingIndices: const [0, 3, 7],
missingIndices: const [0, 1, 2, 3, 7],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final text = req.encodeText();
expect(text, contains(':m0-3.7:'));
final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 3, 7]));
expect(parsed.missingIndices, equals([0, 1, 2, 3, 7]));
});
test('encodes and parses binary fetch request', () {
final req = VoiceFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [1, 4],
requesterKey6: 'ffeeddccbbaa',
timestampSec: 1700000001,
);
final payload = req.encodeBinary();
expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue);
final parsed = VoiceFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([1, 4]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(2));
});
});
group('VoiceFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = VoiceFragmentAck(sessionId: '01020304', index: 7);
final payload = ack.encodeBinary();
expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue);
final parsed = VoiceFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(7));
});
});