mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix null check crash in TabBar
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 +433,26 @@ class AppProvider with ChangeNotifier {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
voiceProvider.waitForFragmentAckCallback = ({
|
voiceProvider.waitForFragmentAckCallback =
|
||||||
required sessionId,
|
({
|
||||||
required index,
|
required sessionId,
|
||||||
timeout = const Duration(seconds: 8),
|
required index,
|
||||||
}) => _waitForVoiceFragmentAck(
|
timeout = const Duration(seconds: 8),
|
||||||
sessionId: sessionId,
|
}) => _waitForVoiceFragmentAck(
|
||||||
index: index,
|
sessionId: sessionId,
|
||||||
timeout: timeout,
|
index: index,
|
||||||
);
|
timeout: timeout,
|
||||||
imageProvider.waitForFragmentAckCallback = ({
|
);
|
||||||
required sessionId,
|
imageProvider.waitForFragmentAckCallback =
|
||||||
required index,
|
({
|
||||||
timeout = const Duration(seconds: 8),
|
required sessionId,
|
||||||
}) => _waitForImageFragmentAck(
|
required index,
|
||||||
sessionId: sessionId,
|
timeout = const Duration(seconds: 8),
|
||||||
index: index,
|
}) => _waitForImageFragmentAck(
|
||||||
timeout: timeout,
|
sessionId: sessionId,
|
||||||
);
|
index: index,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
connectionProvider.onContactReceived = (contact) {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Active service — BLE or TCP depending on current mode
|
/// Active service — BLE or TCP depending on current mode
|
||||||
MeshCoreServiceBase get _activeService =>
|
MeshCoreServiceBase get _activeService =>
|
||||||
(_connectionMode == ConnectionMode.tcp && _tcpService != null)
|
(_connectionMode == ConnectionMode.tcp && _tcpService != null)
|
||||||
? _tcpService!
|
? _tcpService!
|
||||||
: _bleService;
|
: _bleService;
|
||||||
|
|
||||||
/// Current connection mode
|
/// Current connection mode
|
||||||
ConnectionMode _connectionMode = ConnectionMode.ble;
|
ConnectionMode _connectionMode = ConnectionMode.ble;
|
||||||
@@ -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,83 +1679,94 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int count = 0;
|
int totalCount = 0;
|
||||||
_noMoreMessages = false; // Reset flag
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isSyncingMessages = true;
|
_isSyncingMessages = true;
|
||||||
debugPrint('🔄 [Provider] Starting message sync loop...');
|
do {
|
||||||
debugPrint(' Initial _noMoreMessages state: $_noMoreMessages');
|
_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(
|
debugPrint(
|
||||||
'✅ [Provider] Message sync complete - NoMoreMessages flag set after $count requests',
|
'📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE',
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
// Create new completer for this request
|
||||||
'📤 [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE',
|
_syncResponseCompleter = Completer<bool>();
|
||||||
);
|
|
||||||
|
|
||||||
// Create new completer for this request
|
// Respect the minimum interval between requests
|
||||||
_syncResponseCompleter = Completer<bool>();
|
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
|
await _activeService.syncNextMessage();
|
||||||
final now = DateTime.now();
|
_lastSyncNextRequestedAt = DateTime.now();
|
||||||
if (_lastSyncNextRequestedAt != null) {
|
passCount++;
|
||||||
final elapsed = now.difference(_lastSyncNextRequestedAt!);
|
totalCount++;
|
||||||
if (elapsed < _minSyncNextInterval) {
|
|
||||||
final remaining = _minSyncNextInterval - elapsed;
|
// Wait for response (true = message received, false = no more messages)
|
||||||
await Future.delayed(remaining);
|
// 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();
|
if (!_noMoreMessages && passCount >= 100) {
|
||||||
_lastSyncNextRequestedAt = DateTime.now();
|
debugPrint(
|
||||||
count++;
|
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
|
||||||
|
);
|
||||||
// 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 && count >= 100) {
|
if (_syncRequestedWhileBusy) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages',
|
'↻ [Provider] MSG_WAITING received during sync; running another pass',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} while (_syncRequestedWhileBusy && _activeService.isConnected);
|
||||||
|
|
||||||
debugPrint(
|
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) {
|
} 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;
|
||||||
|
|||||||
102
lib/providers/helpers/raw_session_retransmit.dart
Normal file
102
lib/providers/helpers/raw_session_retransmit.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
sessionId: sessionId,
|
||||||
}
|
requester: requester,
|
||||||
if (requester.outPathLen < 0) {
|
fragments: cached.fragments,
|
||||||
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
return false;
|
indexOf: (fragment) => fragment.index,
|
||||||
}
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
sendRawPacket: sendRawPacketCallback,
|
||||||
debugPrint(
|
waitForFragmentAck: waitForFragmentAckCallback,
|
||||||
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
requestedIndices: requestedIndices,
|
||||||
);
|
|
||||||
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 true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
sessionId: sessionId,
|
||||||
}
|
requester: requester,
|
||||||
if (requester.outPathLen < 0) {
|
fragments: cached.packets,
|
||||||
debugPrint(
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
|
indexOf: (packet) => packet.index,
|
||||||
);
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
return false;
|
sendRawPacket: sendRawPacketCallback,
|
||||||
}
|
waitForFragmentAck: waitForFragmentAckCallback,
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
requestedIndices: requestedIndices,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playback ─────────────────────────────────────────────────────────────
|
// ── Playback ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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) {
|
||||||
onNavigateToMap: _isMapEnabled
|
case _HomeTab.messages:
|
||||||
? () => _tabController.animateTo(2)
|
return MessagesTab(
|
||||||
: null,
|
onNavigateToMap: _isMapEnabled
|
||||||
),
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
ContactsTab(
|
: null,
|
||||||
onNavigateToMap: _isMapEnabled
|
);
|
||||||
? () => _tabController.animateTo(2)
|
case _HomeTab.contacts:
|
||||||
: null,
|
return ContactsTab(
|
||||||
),
|
onNavigateToMap: _isMapEnabled
|
||||||
if (_isMapEnabled)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
MapTab(
|
: null,
|
||||||
onFullscreenChanged: (isFullscreen) {
|
);
|
||||||
setState(() {
|
case _HomeTab.map:
|
||||||
_isMapFullscreen = isFullscreen;
|
return MapTab(
|
||||||
});
|
onFullscreenChanged: (isFullscreen) {
|
||||||
},
|
setState(() {
|
||||||
onNavigateToMessages: () => _tabController.animateTo(0),
|
_isMapFullscreen = isFullscreen;
|
||||||
),
|
});
|
||||||
],
|
},
|
||||||
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: shouldHideUI
|
bottomNavigationBar: shouldHideUI || !shouldShowTabBar
|
||||||
? 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) {
|
||||||
icon: _buildTabIconWithBadge(
|
case _HomeTab.messages:
|
||||||
Icons.message,
|
return Tab(
|
||||||
unreadCount,
|
icon: _buildTabIconWithBadge(
|
||||||
),
|
Icons.message,
|
||||||
text: AppLocalizations.of(context)!.messages,
|
unreadCount,
|
||||||
),
|
),
|
||||||
Tab(
|
text: AppLocalizations.of(context)!.messages,
|
||||||
icon: _buildTabIconWithBadge(
|
);
|
||||||
Icons.contacts,
|
case _HomeTab.contacts:
|
||||||
newContactsCount,
|
return Tab(
|
||||||
),
|
icon: _buildTabIconWithBadge(
|
||||||
text: AppLocalizations.of(context)!.contacts,
|
Icons.contacts,
|
||||||
),
|
newContactsCount,
|
||||||
if (_isMapEnabled)
|
),
|
||||||
Tab(
|
text: AppLocalizations.of(context)!.contacts,
|
||||||
icon: const Icon(Icons.map),
|
);
|
||||||
text: AppLocalizations.of(context)!.map,
|
case _HomeTab.map:
|
||||||
),
|
return Tab(
|
||||||
],
|
icon: const Icon(Icons.map),
|
||||||
|
text: AppLocalizations.of(context)!.map,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
145
test/providers/helpers/raw_session_retransmit_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user