Fix null check crash in TabBar

This commit is contained in:
Janez T
2026-03-05 11:41:15 +01:00
parent 234edf5bb0
commit 0cb1d49804
14 changed files with 660 additions and 286 deletions

View File

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

View File

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

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.0002"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.001023">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.455154"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.446008">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="118.952769"> <testcase classname="fastlane.lanes" name="2: build_app" time="104.515166">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="737.001659"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="268.810436">
</testcase> </testcase>

View File

@@ -42,6 +42,8 @@ class AppProvider with ChangeNotifier {
bool _isMapEnabled = true; bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled; bool get isMapEnabled => _isMapEnabled;
bool _isContactsEnabled = true;
bool get isContactsEnabled => _isContactsEnabled;
bool _isVoiceSilenceTrimmingEnabled = true; bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
@@ -81,6 +83,7 @@ class AppProvider with ChangeNotifier {
_initializeLocationTracking(); _initializeLocationTracking();
_loadSimpleMode(); _loadSimpleMode();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled();
_loadVoiceSilenceTrimmingEnabled(); _loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
@@ -214,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. /// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async { Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try { try {
@@ -407,7 +433,8 @@ class AppProvider with ChangeNotifier {
payload: payload, payload: payload,
); );
}; };
voiceProvider.waitForFragmentAckCallback = ({ voiceProvider.waitForFragmentAckCallback =
({
required sessionId, required sessionId,
required index, required index,
timeout = const Duration(seconds: 8), timeout = const Duration(seconds: 8),
@@ -416,7 +443,8 @@ class AppProvider with ChangeNotifier {
index: index, index: index,
timeout: timeout, timeout: timeout,
); );
imageProvider.waitForFragmentAckCallback = ({ imageProvider.waitForFragmentAckCallback =
({
required sessionId, required sessionId,
required index, required index,
timeout = const Duration(seconds: 8), timeout = const Duration(seconds: 8),

View File

@@ -117,6 +117,8 @@ class ConnectionProvider with ChangeNotifier {
bool _noMoreMessages = false; bool _noMoreMessages = false;
// Prevent overlapping/too-frequent sync requests // Prevent overlapping/too-frequent sync requests
bool _isSyncingMessages = false; bool _isSyncingMessages = false;
// If MSG_WAITING arrives while a sync loop is active, queue one more pass.
bool _syncRequestedWhileBusy = false;
DateTime? _lastSyncNextRequestedAt; DateTime? _lastSyncNextRequestedAt;
static const Duration _minSyncNextInterval = Duration(milliseconds: 150); static const Duration _minSyncNextInterval = Duration(milliseconds: 150);
@@ -310,7 +312,14 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageWaiting = () { service.onMessageWaiting = () {
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); 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 = service.onLoginSuccess =
@@ -558,7 +567,9 @@ class ConnectionProvider with ChangeNotifier {
final success = await _tcpService!.connect(host, port); final success = await _tcpService!.connect(host, port);
if (!success) { if (!success) {
_deviceInfo = _deviceInfo.copyWith(connectionState: ConnectionState.error); _deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
notifyListeners(); notifyListeners();
} }
return success; return success;
@@ -1169,7 +1180,10 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Text: $text'); debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId'); debugPrint(' MessageID: $messageId');
await _activeService.sendChannelMessage(channelIdx: channelIdx, text: text); await _activeService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
debugPrint('✅ [ConnectionProvider] BLE send completed'); debugPrint('✅ [ConnectionProvider] BLE send completed');
debugPrint( debugPrint(
@@ -1655,6 +1669,7 @@ class ConnectionProvider with ChangeNotifier {
Future<int> syncAllMessages() async { Future<int> syncAllMessages() async {
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
return 0; return 0;
} }
@@ -1664,11 +1679,14 @@ class ConnectionProvider with ChangeNotifier {
return 0; return 0;
} }
int count = 0; int totalCount = 0;
_noMoreMessages = false; // Reset flag
try { try {
_isSyncingMessages = true; _isSyncingMessages = true;
do {
_syncRequestedWhileBusy = false;
_noMoreMessages = false; // Reset flag per pass
int passCount = 0;
debugPrint('🔄 [Provider] Starting message sync loop...'); debugPrint('🔄 [Provider] Starting message sync loop...');
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages'); debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
@@ -1680,7 +1698,7 @@ class ConnectionProvider with ChangeNotifier {
// Check flag BEFORE sending (not after) // Check flag BEFORE sending (not after)
if (_noMoreMessages) { if (_noMoreMessages) {
debugPrint( debugPrint(
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests', '✅ [Provider] Message sync complete - NoMoreMessages flag set after $passCount requests',
); );
break; break;
} }
@@ -1704,7 +1722,8 @@ class ConnectionProvider with ChangeNotifier {
await _activeService.syncNextMessage(); await _activeService.syncNextMessage();
_lastSyncNextRequestedAt = DateTime.now(); _lastSyncNextRequestedAt = DateTime.now();
count++; passCount++;
totalCount++;
// Wait for response (true = message received, false = no more messages) // Wait for response (true = message received, false = no more messages)
// Timeout after 2 seconds to prevent hanging // Timeout after 2 seconds to prevent hanging
@@ -1726,21 +1745,28 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
if (!_noMoreMessages && count >= 100) { if (!_noMoreMessages && passCount >= 100) {
debugPrint( debugPrint(
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages', '⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
); );
} }
if (_syncRequestedWhileBusy) {
debugPrint( debugPrint(
'🏁 [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages', ' [Provider] MSG_WAITING received during sync; running another pass',
); );
return count; }
} while (_syncRequestedWhileBusy && _activeService.isConnected);
debugPrint(
'🏁 [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages',
);
return totalCount;
} catch (e) { } catch (e) {
debugPrint('❌ [Provider] Failed to sync messages: $e'); debugPrint('❌ [Provider] Failed to sync messages: $e');
_error = 'Failed to sync messages: $e'; _error = 'Failed to sync messages: $e';
notifyListeners(); notifyListeners();
return count; return totalCount;
} finally { } finally {
_isSyncingMessages = false; _isSyncingMessages = false;
_syncResponseCompleter = null; _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:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
/// Reassembly state for one incoming image session. /// 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 /// Register envelope metadata for a session (called when IE1 is received
/// before any binary fragments arrive). /// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) { void registerEnvelope(ImageEnvelope envelope) {
_sessions.putIfAbsent( final existing = _sessions[envelope.sessionId];
envelope.sessionId, if (existing == null) {
() => 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) {
_sessions[envelope.sessionId] = ImageSession( _sessions[envelope.sessionId] = ImageSession(
sessionId: envelope.sessionId, sessionId: envelope.sessionId,
format: envelope.format, format: envelope.format,
@@ -157,12 +147,38 @@ class ImageProvider with ChangeNotifier {
width: envelope.width, width: envelope.width,
height: envelope.height, height: envelope.height,
); );
// Copy existing fragments into the new session. unawaited(_persist());
final old = _sessions[envelope.sessionId]!; notifyListeners();
for (var i = 0; i < session.fragments.length && i < old.total; i++) { return;
old.fragments[i] = session.fragments[i]; }
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(); notifyListeners();
} }
@@ -214,55 +230,18 @@ class ImageProvider with ChangeNotifier {
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
return false; return false;
} }
if (sendRawPacketCallback == null) { return serveCachedSessionFragments<ImagePacket>(
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set'); providerLabel: 'ImageProvider',
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, sessionId: sessionId,
index: fragment.index, requester: requester,
timeout: const Duration(seconds: 8), fragments: cached.fragments,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
); );
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 true;
} }
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_player_service.dart'; import '../services/voice_player_service.dart';
@@ -181,56 +182,18 @@ class VoiceProvider with ChangeNotifier {
); );
return false; return false;
} }
if (sendRawPacketCallback == null) { return serveCachedSessionFragments<VoicePacket>(
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set'); providerLabel: 'VoiceProvider',
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, sessionId: sessionId,
index: packet.index, requester: requester,
timeout: const Duration(seconds: 8), fragments: cached.packets,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
); );
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;
} }
// ── Playback ───────────────────────────────────────────────────────────── // ── Playback ─────────────────────────────────────────────────────────────

View File

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

View File

@@ -727,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( ListTile(
leading: const Icon(Icons.language), leading: const Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language), title: Text(AppLocalizations.of(context)!.language),

View File

@@ -59,6 +59,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>( return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) { builder: (context, imageProvider, _) {
final session = imageProvider.session(envelope.sessionId); 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 isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime( final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId, envelope.sessionId,
@@ -98,6 +103,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
pathLen: effectivePathLen,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -109,13 +115,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
received: received, received: received,
total: total, total: total,
envelope: envelope, envelope: envelope,
pathLen: widget.message.pathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
error: _errorText, error: _errorText,
isSentByMe: widget.isSentByMe, isSentByMe: widget.isSentByMe,
eta: eta, eta: eta,
pathLen: effectivePathLen,
), ),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
@@ -143,6 +149,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required int? radioBw, required int? radioBw,
required int? radioSf, required int? radioSf,
required int? radioCr, required int? radioCr,
required int pathLen,
}) { }) {
if (isComplete && imageBytes != null) { if (isComplete && imageBytes != null) {
return AspectRatio( return AspectRatio(
@@ -184,7 +191,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
pathLen: widget.message.pathLen, pathLen: pathLen,
), ),
icon: const Icon(Icons.download_rounded, size: 40), icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70, color: Colors.white70,
@@ -298,12 +305,13 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return; if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = estimateImageTransmitDuration( final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length, fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty sizeBytes: missing.isEmpty
? envelope.sizeBytes ? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(), : (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: pathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
@@ -335,6 +343,19 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
); );
if (contact != null) return contact; 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(); final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) { if (senderName != null && senderName.isNotEmpty) {
for (final c in contactsProvider.contacts) { for (final c in contactsProvider.contacts) {

View File

@@ -22,6 +22,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text); final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) { if (event == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -82,6 +83,12 @@ class TicTacToeMessageBubble extends StatelessWidget {
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O'; final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol; 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( return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 230), constraints: const BoxConstraints(maxWidth: 230),
@@ -92,12 +99,16 @@ class TicTacToeMessageBubble extends StatelessWidget {
'Tic-Tac-Toe · Game ${state.gameId}', 'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold), ).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_BoardGrid( _BoardGrid(
board: state.board, board: state.board,
enabled: isMyTurn, enabled: isMyTurn,
isSentByMe: isSentByMe,
onTapCell: (idx) => _onCellTap( onTapCell: (idx) => _onCellTap(
context: context, context: context,
idx: idx, idx: idx,
@@ -110,7 +121,9 @@ class TicTacToeMessageBubble extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_statusText(state: state, mySymbol: mySymbol), _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 { class _BoardGrid extends StatelessWidget {
final List<String?> board; final List<String?> board;
final bool enabled; final bool enabled;
final bool isSentByMe;
final ValueChanged<int> onTapCell; final ValueChanged<int> onTapCell;
const _BoardGrid({ const _BoardGrid({
required this.board, required this.board,
required this.enabled, required this.enabled,
required this.isSentByMe,
required this.onTapCell, required this.onTapCell,
}); });
@override @override
Widget build(BuildContext context) { 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( return SizedBox(
width: 180, width: 180,
height: 180, height: 180,
@@ -251,12 +274,18 @@ class _BoardGrid extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: cellBackground,
border: Border.all(color: cellBorder),
), ),
child: Text( child: Text(
value ?? '', value ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: value == 'X'
? colorScheme.primary
: value == 'O'
? colorScheme.tertiary
: null,
), ),
), ),
), ),

View File

@@ -57,6 +57,11 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
builder: (context, voiceProvider, _) { builder: (context, voiceProvider, _) {
final session = voiceProvider.session(voiceId); final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text); 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 isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId); final isComplete = voiceProvider.isComplete(voiceId);
@@ -96,7 +101,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
session: session, session: session,
envelope: envelope, envelope: envelope,
messageText: widget.message.text, messageText: widget.message.text,
pathLen: widget.message.pathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
@@ -123,7 +128,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
pathLen: widget.message.pathLen, pathLen: effectivePathLen,
); );
}, },
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -280,12 +285,13 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
// Timeout = 2× estimated LoRa airtime (min 30s). // Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
final txEstimate = envelope != null final txEstimate = envelope != null
? estimateVoiceTransmitDuration( ? estimateVoiceTransmitDuration(
packetCount: envelope.total, packetCount: envelope.total,
mode: envelope.mode, mode: envelope.mode,
durationMs: envelope.durationMs, durationMs: envelope.durationMs,
pathLen: pathLen, pathLen: effectivePathLen,
radioBw: radioBw, radioBw: radioBw,
radioSf: radioSf, radioSf: radioSf,
radioCr: radioCr, radioCr: radioCr,
@@ -329,6 +335,19 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (contact != null) return contact; 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(); final senderName = widget.message.senderName?.trim();
if (senderName != null && senderName.isNotEmpty) { if (senderName != null && senderName.isNotEmpty) {
for (final contact in contactsProvider.contacts) { for (final contact in contactsProvider.contacts) {

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);
});
});
}