diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 3e11938..9d7850c 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
- CURRENT_PROJECT_VERSION = 75;
+ CURRENT_PROJECT_VERSION = 77;
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 = 75;
+ CURRENT_PROJECT_VERSION = 77;
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 = 75;
+ CURRENT_PROJECT_VERSION = 77;
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 = 75;
+ CURRENT_PROJECT_VERSION = 77;
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 = 75;
+ CURRENT_PROJECT_VERSION = 77;
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 = 75;
+ CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 4cfcf26..8acb499 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -43,7 +43,7 @@
CFBundleSignature
????
CFBundleVersion
- 75
+ 77
LSRequiresIPhoneOS
NSBluetoothAlwaysUsageDescription
diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml
index b9dd224..db2228f 100644
--- a/ios/fastlane/report.xml
+++ b/ios/fastlane/report.xml
@@ -5,22 +5,22 @@
-
+
-
+
-
+
-
+
diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart
index ed50a99..4d296fd 100644
--- a/lib/providers/app_provider.dart
+++ b/lib/providers/app_provider.dart
@@ -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;
@@ -81,6 +83,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking();
_loadSimpleMode();
_loadMapEnabled();
+ _loadContactsEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
@@ -214,6 +217,29 @@ class AppProvider with ChangeNotifier {
}
}
+ /// Load contacts enabled setting from shared preferences
+ Future _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 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 _loadVoiceSilenceTrimmingEnabled() async {
try {
@@ -407,24 +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,
- );
+ 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) {
diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart
index 3f51784..0775ae7 100644
--- a/lib/providers/connection_provider.dart
+++ b/lib/providers/connection_provider.dart
@@ -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 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();
- // Create new completer for this request
- _syncResponseCompleter = Completer();
+ // 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;
diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart
new file mode 100644
index 0000000..fb8f1c5
--- /dev/null
+++ b/lib/providers/helpers/raw_session_retransmit.dart
@@ -0,0 +1,102 @@
+import 'package:flutter/foundation.dart';
+
+import '../../models/contact.dart';
+
+typedef RawPacketSender =
+ Future Function({
+ required Uint8List contactPath,
+ required int contactPathLen,
+ required Uint8List payload,
+ });
+
+typedef FragmentAckWaiter =
+ Future Function({
+ required String sessionId,
+ required int index,
+ Duration timeout,
+ });
+
+Future serveCachedSessionFragments({
+ required String providerLabel,
+ required String sessionId,
+ required Contact requester,
+ required List fragments,
+ required int maxDirectPayloadHops,
+ required int Function(T fragment) indexOf,
+ required Uint8List Function(T fragment) encodeBinary,
+ required RawPacketSender? sendRawPacket,
+ FragmentAckWaiter? waitForFragmentAck,
+ Set? 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;
+}
diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart
index 89a08b6..dc93dfd 100644
--- a/lib/providers/image_provider.dart
+++ b/lib/providers/image_provider.dart
@@ -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.
@@ -137,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,
@@ -157,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();
}
@@ -214,55 +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 {
- final ackFuture = waitForFragmentAckCallback?.call(
- sessionId: sessionId,
- index: fragment.index,
- timeout: const Duration(seconds: 8),
- );
- await sendRawPacketCallback!(
- contactPath: requester.outPath,
- contactPathLen: requester.outPathLen,
- payload: fragment.encodeBinary(),
- );
- if (ackFuture != null) {
- final acked = await ackFuture;
- if (!acked) {
- debugPrint(
- 'โ ๏ธ [ImageProvider] ACK timeout for $sessionId#${fragment.index}',
- );
- return false;
- }
- }
- } 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(
+ 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart
index f54f88e..33e3d10 100644
--- a/lib/providers/voice_provider.dart
+++ b/lib/providers/voice_provider.dart
@@ -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';
@@ -181,56 +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 {
- final ackFuture = waitForFragmentAckCallback?.call(
- sessionId: sessionId,
- index: packet.index,
- timeout: const Duration(seconds: 8),
- );
- await sendRawPacketCallback!(
- contactPath: requester.outPath,
- contactPathLen: requester.outPathLen,
- payload: packet.encodeBinary(),
- );
- if (ackFuture != null) {
- final acked = await ackFuture;
- if (!acked) {
- debugPrint(
- 'โ ๏ธ [VoiceProvider] ACK timeout for $sessionId#${packet.index}',
- );
- return false;
- }
- }
- } catch (e, st) {
- debugPrint(
- 'โ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
- );
- return false;
- }
- }
- return true;
+ return serveCachedSessionFragments(
+ 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 7445e4a..e1463ed 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -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
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
}
}
- Future _loadMapEnabledAndInitTabs() async {
+ Future _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 _loadRxTxPreference() async {
@@ -276,18 +312,23 @@ class _HomeScreenState extends State
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();
- 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
),
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(
builder: (context, messagesProvider, contactsProvider, child) {
@@ -415,27 +460,31 @@ class _HomeScreenState extends State
),
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(),
),
);
},
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 813d08e..2225dc4 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -727,6 +727,19 @@ class _SettingsScreenState extends State {
},
),
),
+ Consumer(
+ 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),
diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart
index 0ac6d75..ae11fb0 100644
--- a/lib/widgets/messages/image_message_bubble.dart
+++ b/lib/widgets/messages/image_message_bubble.dart
@@ -59,6 +59,11 @@ class _ImageMessageBubbleState extends State {
return Consumer(
builder: (context, imageProvider, _) {
final session = imageProvider.session(envelope.sessionId);
+ final sender = _resolveSender(envelope);
+ final effectivePathLen =
+ sender != null && sender.outPathLen >= 0
+ ? sender.outPathLen
+ : widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId,
@@ -98,6 +103,7 @@ class _ImageMessageBubbleState extends State {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
+ pathLen: effectivePathLen,
),
),
const SizedBox(height: 4),
@@ -109,13 +115,13 @@ class _ImageMessageBubbleState extends State {
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,
@@ -143,6 +149,7 @@ class _ImageMessageBubbleState extends State {
required int? radioBw,
required int? radioSf,
required int? radioCr,
+ required int pathLen,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
@@ -184,7 +191,7 @@ class _ImageMessageBubbleState extends State {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
- pathLen: widget.message.pathLen,
+ pathLen: pathLen,
),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70,
@@ -298,12 +305,13 @@ class _ImageMessageBubbleState extends State {
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,
@@ -335,6 +343,19 @@ class _ImageMessageBubbleState extends State {
);
if (contact != null) return contact;
+ // For sent direct messages, fetch must target the recipient peer.
+ final recipientKey = widget.message.recipientPublicKey;
+ if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) {
+ final byKey = contactsProvider.findContactByKey(recipientKey);
+ if (byKey != null) return byKey;
+ if (recipientKey.length >= 6) {
+ final byPrefix = contactsProvider.findContactByPrefix(
+ Uint8List.fromList(recipientKey.sublist(0, 6)),
+ );
+ if (byPrefix != null) return byPrefix;
+ }
+ }
+
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final c in contactsProvider.contacts) {
diff --git a/lib/widgets/messages/tictactoe_message_bubble.dart b/lib/widgets/messages/tictactoe_message_bubble.dart
index 6124732..0b3b6a6 100644
--- a/lib/widgets/messages/tictactoe_message_bubble.dart
+++ b/lib/widgets/messages/tictactoe_message_bubble.dart
@@ -22,6 +22,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) {
return const SizedBox.shrink();
@@ -82,6 +83,12 @@ class TicTacToeMessageBubble extends StatelessWidget {
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),
@@ -92,12 +99,16 @@ class TicTacToeMessageBubble extends StatelessWidget {
'Tic-Tac-Toe ยท Game ${state.gameId}',
style: Theme.of(
context,
- ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold),
+ ).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,
@@ -110,7 +121,9 @@ class TicTacToeMessageBubble extends StatelessWidget {
const SizedBox(height: 8),
Text(
_statusText(state: state, mySymbol: mySymbol),
- style: Theme.of(context).textTheme.labelSmall,
+ style: Theme.of(
+ context,
+ ).textTheme.labelSmall?.copyWith(color: statusColor),
),
],
),
@@ -221,16 +234,26 @@ class TicTacToeMessageBubble extends StatelessWidget {
class _BoardGrid extends StatelessWidget {
final List board;
final bool enabled;
+ final bool isSentByMe;
final ValueChanged 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,
@@ -251,12 +274,18 @@ class _BoardGrid extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
- color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ 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,
),
),
),
diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart
index b73e037..07a2cc3 100644
--- a/lib/widgets/messages/voice_message_bubble.dart
+++ b/lib/widgets/messages/voice_message_bubble.dart
@@ -57,6 +57,11 @@ class _VoiceMessageBubbleState extends State {
builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
+ final sender = _resolveSenderContact();
+ final effectivePathLen =
+ sender != null && sender.outPathLen >= 0
+ ? sender.outPathLen
+ : widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
@@ -96,7 +101,7 @@ class _VoiceMessageBubbleState extends State {
session: session,
envelope: envelope,
messageText: widget.message.text,
- pathLen: widget.message.pathLen,
+ pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
@@ -123,7 +128,7 @@ class _VoiceMessageBubbleState extends State {
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
- pathLen: widget.message.pathLen,
+ pathLen: effectivePathLen,
);
},
borderRadius: BorderRadius.circular(24),
@@ -280,12 +285,13 @@ class _VoiceMessageBubbleState extends State {
}
// 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,
@@ -329,6 +335,19 @@ class _VoiceMessageBubbleState extends State {
if (contact != null) return contact;
}
+ // For sent direct messages, fetch must target the recipient peer.
+ final recipientKey = widget.message.recipientPublicKey;
+ if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) {
+ final byKey = contactsProvider.findContactByKey(recipientKey);
+ if (byKey != null) return byKey;
+ if (recipientKey.length >= 6) {
+ final byPrefix = contactsProvider.findContactByPrefix(
+ Uint8List.fromList(recipientKey.sublist(0, 6)),
+ );
+ if (byPrefix != null) return byPrefix;
+ }
+ }
+
final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) {
for (final contact in contactsProvider.contacts) {
diff --git a/test/providers/helpers/raw_session_retransmit_test.dart b/test/providers/helpers/raw_session_retransmit_test.dart
new file mode 100644
index 0000000..5f83aa1
--- /dev/null
+++ b/test/providers/helpers/raw_session_retransmit_test.dart
@@ -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.generate(32, (i) => i)),
+ type: ContactType.chat,
+ flags: 0,
+ outPathLen: outPathLen,
+ outPath: Uint8List.fromList(List.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 = [];
+ final waited = [];
+ 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);
+ });
+ });
+}