Compare commits

..

13 Commits

Author SHA1 Message Date
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
47 changed files with 7024 additions and 977 deletions

3
.gitignore vendored
View File

@@ -80,10 +80,11 @@ create_feature_graphic.py
# Tool caches and local state # Tool caches and local state
.cachebro/ .cachebro/
.osgrep/ .osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config) # Claude Code local settings (permissions, personal config)
.claude/settings.local.json .claude/settings.local.json
# Dart code coverage # Dart code coverage
coverage/ coverage/
lcov.info lcov.info

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 = 111; CURRENT_PROJECT_VERSION = 112;
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 = 111; CURRENT_PROJECT_VERSION = 112;
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 = 111; CURRENT_PROJECT_VERSION = 112;
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 = 111; CURRENT_PROJECT_VERSION = 112;
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 = 111; CURRENT_PROJECT_VERSION = 112;
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 = 111; CURRENT_PROJECT_VERSION = 112;
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>111</string> <string>112</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.924905"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="113.366384"> <testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="755.542625"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
</testcase> </testcase>

View File

@@ -0,0 +1,31 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@@ -1,7 +1,10 @@
enum PathRecordSource { learned, observed }
class PathRecord { class PathRecord {
final List<int> pathBytes; final List<int> pathBytes;
final int hopCount; final int hopCount;
final int hashSize; final int hashSize;
final PathRecordSource source;
final int successCount; final int successCount;
final int failureCount; final int failureCount;
final int lastRoundTripTimeMs; final int lastRoundTripTimeMs;
@@ -11,6 +14,7 @@ class PathRecord {
required this.pathBytes, required this.pathBytes,
required this.hopCount, required this.hopCount,
required this.hashSize, required this.hashSize,
required this.source,
required this.successCount, required this.successCount,
required this.failureCount, required this.failureCount,
required this.lastRoundTripTimeMs, required this.lastRoundTripTimeMs,
@@ -27,6 +31,7 @@ class PathRecord {
List<int>? pathBytes, List<int>? pathBytes,
int? hopCount, int? hopCount,
int? hashSize, int? hashSize,
PathRecordSource? source,
int? successCount, int? successCount,
int? failureCount, int? failureCount,
int? lastRoundTripTimeMs, int? lastRoundTripTimeMs,
@@ -36,6 +41,7 @@ class PathRecord {
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount, hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize, hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount, successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount, failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
@@ -48,6 +54,7 @@ class PathRecord {
'path_bytes': pathBytes, 'path_bytes': pathBytes,
'hop_count': hopCount, 'hop_count': hopCount,
'hash_size': hashSize, 'hash_size': hashSize,
'source': source.name,
'success_count': successCount, 'success_count': successCount,
'failure_count': failureCount, 'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs, 'last_round_trip_time_ms': lastRoundTripTimeMs,
@@ -62,6 +69,10 @@ class PathRecord {
.toList(), .toList(),
hopCount: json['hop_count'] as int? ?? 0, hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1, hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0, successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
@@ -163,6 +174,10 @@ class ContactPathHistory {
}; };
} }
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson( factory ContactPathHistory.fromJson(
String contactPublicKeyHex, String contactPublicKeyHex,
Map<String, dynamic> json, Map<String, dynamic> json,

View File

@@ -91,6 +91,12 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true; bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
bool _isVoiceAutoGainEnabled = false;
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
bool _isVoiceEchoCancellationEnabled = false;
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
bool _isVoiceNoiseSuppressionEnabled = false;
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false; bool _autoAddDiscoveredContacts = false;
@@ -142,6 +148,8 @@ class AppProvider with ChangeNotifier {
required this.imageProvider, required this.imageProvider,
}) { }) {
_setupCallbacks(); _setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
@@ -151,6 +159,9 @@ class AppProvider with ChangeNotifier {
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled(); _loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadAutoAddDiscoveredContacts(); _loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
@@ -439,6 +450,72 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> _loadVoiceAutoGainEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled =
prefs.getBool('voice_auto_gain_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice auto gain setting: $e');
}
}
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
try {
_isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_auto_gain_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice auto gain setting: $e');
}
}
Future<void> _loadVoiceEchoCancellationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled =
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e');
}
}
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
try {
_isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e');
}
}
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled =
prefs.getBool('voice_noise_suppression_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice noise suppression setting: $e');
}
}
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
try {
_isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_noise_suppression_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice noise suppression setting: $e');
}
}
Future<void> _loadMessageFontScale() async { Future<void> _loadMessageFontScale() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -775,7 +852,6 @@ class AppProvider with ChangeNotifier {
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null && if (senderContact != null &&
enrichedMessage.isChannelMessage && enrichedMessage.isChannelMessage &&
(enrichedMessage.channelIdx ?? 0) == 0 &&
receivedPathBytes != null && receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) { receivedPathBytes.isNotEmpty) {
unawaited( unawaited(
@@ -1180,6 +1256,7 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', '📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
); );
unawaited(_retainAdvertRxPath(publicKey));
// Check if this is an existing contact that might have updated location // Check if this is an existing contact that might have updated location
final contact = contactsProvider.findContactByKey(publicKey); final contact = contactsProvider.findContactByKey(publicKey);
if (contact != null) { if (contact != null) {
@@ -1293,6 +1370,9 @@ class AppProvider with ChangeNotifier {
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
); );
}; };
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(contactPublicKey);
};
messagesProvider.onFinalRouterFallbackCallback = messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async { ({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback( return _sendWithFinalNearestRouterFallback(
@@ -1321,6 +1401,9 @@ class AppProvider with ChangeNotifier {
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
); );
}; };
messagesProvider.onManualRetryPreparedCallback = (messageId) {
_directMessageRouteSessions.remove(messageId);
};
} }
Future<Contact> _prepareDirectMessageSend({ Future<Contact> _prepareDirectMessageSend({
@@ -1583,6 +1666,38 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) {
return;
}
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse(
reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize,
);
contactsProvider.retainReceivedRoute(
publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
}
int _inferReceivedPathHashSize( int _inferReceivedPathHashSize(
List<int> pathBytes, { List<int> pathBytes, {
required int preferredHashSize, required int preferredHashSize,
@@ -1604,6 +1719,8 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
try { try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self // Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact // If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering // This must happen before getContacts to ensure proper filtering
@@ -1655,7 +1772,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)', '🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
); );
final initialMessageCount = await connectionProvider.syncAllMessages(); final initialMessageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)', '📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
); );
@@ -1677,19 +1796,26 @@ class AppProvider with ChangeNotifier {
_hasCompletedConnectionBootstrap = true; _hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Initialization error: $e'); debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
} }
} }
Future<void> _syncAfterReconnect() async { Future<void> _syncAfterReconnect({bool started = false}) async {
if (_isReconnectSyncInProgress || if (!connectionProvider.deviceInfo.isConnected) {
!connectionProvider.deviceInfo.isConnected) {
return; return;
} }
_isReconnectSyncInProgress = true; if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try { try {
debugPrint( debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages', '🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
@@ -1700,14 +1826,19 @@ class AppProvider with ChangeNotifier {
); );
await connectionProvider.getContacts(); await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(); final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)', '📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
); );
} catch (e) { } catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e'); debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally { } finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false; _isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
} }
} }
@@ -2629,6 +2760,37 @@ class AppProvider with ChangeNotifier {
return bestLog; return bestLog;
} }
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
final publicKeyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
DecodedLogRxRoute? bestRoute;
var bestDeltaMs = 999999999;
final now = DateTime.now();
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != 0x04) continue;
final senderHash = decoded.originalSenderHashHex;
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestRoute = decoded;
}
}
if (bestDeltaMs > 30000) return null;
return bestRoute;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final decoded = LogRxRouteDecoder.decode(log.rawData); final decoded = LogRxRouteDecoder.decode(log.rawData);
@@ -2668,7 +2830,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)', '🔄 [AppProvider] Manual message sync requested (user initiated)',
); );
final messageCount = await connectionProvider.syncAllMessages(); final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages', '✅ [AppProvider] Manual sync completed: $messageCount messages',
); );
@@ -2697,9 +2861,32 @@ class AppProvider with ChangeNotifier {
_stopLocationTracking(); _stopLocationTracking();
} }
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) { if (!isConnected) {
unawaited(_syncAfterReconnect()); connectionProvider.clearPendingAutomaticMessageSync();
} }
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
} }
/// Start location tracking /// Start location tracking

View File

@@ -185,9 +185,12 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
// Track pending send operations for auto-recovery // Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {}; final Map<String, _PendingSendOperation> _pendingSendOperations = {};
bool _pendingAutomaticMessageSync = false;
ConnectionProvider() { ConnectionProvider() {
_wireServiceCallbacks(_bleService); _wireServiceCallbacks(_bleService);
@@ -332,6 +335,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan'); debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return; return;
} }
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
);
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) { if (_isSyncingMessages) {
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1164,6 +1174,7 @@ class ConnectionProvider with ChangeNotifier {
} }
var effectiveContact = contact; var effectiveContact = contact;
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
if (messageId != null && if (messageId != null &&
effectiveContact != null && effectiveContact != null &&
prepareDirectMessageSendCallback != null) { prepareDirectMessageSendCallback != null) {
@@ -1892,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier {
} }
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages() async { Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) { if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0; return 0;
} }
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
);
return 0;
}
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1912,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier {
int totalCount = 0; int totalCount = 0;
try { try {
_pendingAutomaticMessageSync = false;
_isSyncingMessages = true; _isSyncingMessages = true;
do { do {
_syncRequestedWhileBusy = false; _syncRequestedWhileBusy = false;
@@ -2003,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
void clearPendingAutomaticMessageSync() {
_pendingAutomaticMessageSync = false;
}
/// Login to a room or repeater /// Login to a room or repeater
/// ///
/// Sends login request with password. Results will be delivered via /// Sends login request with password. Results will be delivered via

View File

@@ -1,6 +1,8 @@
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart'; import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart'; import '../services/contact_storage_service.dart';
@@ -10,8 +12,15 @@ import '../utils/key_comparison.dart';
class PendingAdvert { class PendingAdvert {
final Uint8List publicKey; final Uint8List publicKey;
final DateTime receivedAt; final DateTime receivedAt;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
const PendingAdvert({required this.publicKey, required this.receivedAt}); const PendingAdvert({
required this.publicKey,
required this.receivedAt,
this.signedEncodedPathLen,
this.paddedPathBytes,
});
String get publicKeyHex => String get publicKeyHex =>
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
@@ -21,17 +30,36 @@ class PendingAdvert {
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
} }
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) { PendingAdvert copyWith({
Uint8List? publicKey,
DateTime? receivedAt,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
}) {
return PendingAdvert( return PendingAdvert(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt, receivedAt: receivedAt ?? this.receivedAt,
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
); );
} }
} }
class _RetainedRoute {
final int signedEncodedPathLen;
final Uint8List paddedPathBytes;
const _RetainedRoute({
required this.signedEncodedPathLen,
required this.paddedPathBytes,
});
}
/// Contacts Provider - manages contact list and telemetry /// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier { class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
final Map<String, Contact> _contacts = {}; final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {}; final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService(); final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false; bool _isInitialized = false;
@@ -53,6 +81,7 @@ class ContactsProvider with ChangeNotifier {
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...', '📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
); );
final storedContacts = await _storageService.loadContacts(); final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key) // Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey = const publicChannelKey =
@@ -66,8 +95,11 @@ class ContactsProvider with ChangeNotifier {
} }
_isInitialized = true; _isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint( debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts', '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
); );
// Ensure public channel exists after loading // Ensure public channel exists after loading
@@ -97,6 +129,7 @@ class ContactsProvider with ChangeNotifier {
final storedContacts = await _storageService.loadContacts( final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey, excludePublicKey: devicePublicKey,
); );
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key) // Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey = const publicChannelKey =
@@ -110,8 +143,11 @@ class ContactsProvider with ChangeNotifier {
} }
_isInitialized = true; _isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint( debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts', '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
); );
// Ensure public channel exists after loading // Ensure public channel exists after loading
@@ -182,10 +218,97 @@ class ContactsProvider with ChangeNotifier {
} }
List<Contact> get contacts => _contacts.values.toList(); List<Contact> get contacts => _contacts.values.toList();
List<SavedContactGroup> get savedContactGroups =>
List<SavedContactGroup>.from(_savedContactGroups)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<PendingAdvert> get pendingAdverts => List<PendingAdvert> get pendingAdverts =>
_pendingAdverts.values.toList() _pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
.toList();
}
bool hasSavedGroupForFilter(String sectionKey, String query) {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty) {
return false;
}
return _savedContactGroups.any(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
}
Future<void> addSavedGroupForFilter(
String sectionKey,
String query, {
String? label,
}) async {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty ||
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
return;
}
_savedContactGroups.add(
SavedContactGroup(
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
sectionKey: sectionKey,
label: (label ?? query).trim(),
query: query.trim(),
createdAt: DateTime.now(),
),
);
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupById(String id) async {
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere((group) => group.id == id);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupForFilter(
String sectionKey,
String query,
) async {
final normalizedQuery = _normalizeGroupQuery(query);
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> _persistSavedGroups() async {
try {
await _storageService.saveContactGroups(_savedContactGroups);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
}
}
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
List<Contact> get chatContacts => List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen); contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
@@ -338,8 +461,42 @@ class ContactsProvider with ChangeNotifier {
required Contact incomingContact, required Contact incomingContact,
Contact? existingContact, Contact? existingContact,
}) { }) {
final retainedRoute = _retainedRouteForContact(
keyHex: incomingContact.publicKeyHex,
incomingContact: incomingContact,
existingContact: existingContact,
);
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact?.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
incomingContact: incomingContact,
existingContact: existingContact,
retainedRoute: retainedRoute,
mergedTelemetry: mergedTelemetry,
);
final inferredFallbackAdvLat = inferredFallbackLocation != null
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
: null;
final inferredFallbackAdvLon = inferredFallbackLocation != null
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
: null;
if (existingContact == null) { if (existingContact == null) {
var newContact = incomingContact.copyWith(isNew: true); var newContact = incomingContact.copyWith(
isNew: true,
telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
advLat: incomingContact.advertLocation != null
? incomingContact.advLat
: inferredFallbackAdvLat ?? incomingContact.advLat,
advLon: incomingContact.advertLocation != null
? incomingContact.advLon
: inferredFallbackAdvLon ?? incomingContact.advLon,
);
if (incomingContact.advertLocation != null) { if (incomingContact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch( final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000, incomingContact.lastAdvert * 1000,
@@ -352,27 +509,26 @@ class ContactsProvider with ChangeNotifier {
return newContact; return newContact;
} }
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final incomingAdvertLocation = incomingContact.advertLocation; final incomingAdvertLocation = incomingContact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation; final existingAdvertLocation = existingContact.advertLocation;
var updatedContact = incomingContact.copyWith( var updatedContact = incomingContact.copyWith(
isNew: existingContact.isNew, isNew: false,
advertHistory: existingContact.advertHistory, advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry, telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
advLat: incomingAdvertLocation != null advLat: incomingAdvertLocation != null
? incomingContact.advLat ? incomingContact.advLat
: existingAdvertLocation != null : existingAdvertLocation != null
? existingContact.advLat ? existingContact.advLat
: incomingContact.advLat, : inferredFallbackAdvLat ?? incomingContact.advLat,
advLon: incomingAdvertLocation != null advLon: incomingAdvertLocation != null
? incomingContact.advLon ? incomingContact.advLon
: existingAdvertLocation != null : existingAdvertLocation != null
? existingContact.advLon ? existingContact.advLon
: incomingContact.advLon, : inferredFallbackAdvLon ?? incomingContact.advLon,
); );
if (incomingAdvertLocation != null) { if (incomingAdvertLocation != null) {
@@ -388,6 +544,145 @@ class ContactsProvider with ChangeNotifier {
return updatedContact; return updatedContact;
} }
_RetainedRoute? _retainedRouteForContact({
required String keyHex,
required Contact incomingContact,
required Contact? existingContact,
}) {
if (incomingContact.routeHasPath) {
return null;
}
final pendingAdvert = _pendingAdverts[keyHex];
final pendingPathBytes = pendingAdvert?.paddedPathBytes;
final pendingPathLen = pendingAdvert?.signedEncodedPathLen;
if (pendingPathLen != null &&
pendingPathBytes != null &&
pendingPathBytes.isNotEmpty) {
return _RetainedRoute(
signedEncodedPathLen: pendingPathLen,
paddedPathBytes: Uint8List.fromList(pendingPathBytes),
);
}
if (existingContact != null && existingContact.routeHasPath) {
return _RetainedRoute(
signedEncodedPathLen: existingContact.outPathLen,
paddedPathBytes: Uint8List.fromList(existingContact.outPath),
);
}
return null;
}
LatLng? _inferFirstHopFallbackLocation({
required Contact incomingContact,
required Contact? existingContact,
required _RetainedRoute? retainedRoute,
required ContactTelemetry? mergedTelemetry,
}) {
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
return null;
}
if (incomingContact.advertLocation != null ||
existingContact?.advertLocation != null) {
return null;
}
final routeBytes =
retainedRoute?.paddedPathBytes ??
(incomingContact.routeHasPath
? incomingContact.routePathBytes
: existingContact?.routeHasPath == true
? existingContact!.routePathBytes
: null);
final routeHashSize = retainedRoute != null
? ((ContactRouteCodec.toUnsignedDescriptor(
retainedRoute.signedEncodedPathLen,
) >>
6) +
1)
: incomingContact.routeHasPath
? incomingContact.routeHashSize
: existingContact?.routeHasPath == true
? existingContact!.routeHashSize
: 0;
if (routeBytes == null ||
routeHashSize <= 0 ||
routeBytes.length < routeHashSize) {
return null;
}
final lastHopHex = routeBytes
.sublist(routeBytes.length - routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final repeaterCandidates = _contacts.values.where((candidate) {
if (!candidate.isRepeater ||
candidate.publicKeyHex == incomingContact.publicKeyHex) {
return false;
}
final location = candidate.displayLocation;
return location != null && candidate.publicKeyHex.startsWith(lastHopHex);
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
if (repeaterCandidates.isEmpty) {
return null;
}
final repeaterLocation = repeaterCandidates.first.displayLocation;
if (repeaterLocation == null) {
return null;
}
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
return _offsetFromLocation(
repeaterLocation,
distanceMeters: _firstHopFallbackOffsetMeters,
bearingDegrees: bearingDegrees,
);
}
double _stableFallbackBearingDegrees(Contact contact) {
if (contact.publicKey.length < 2) {
return 90.0;
}
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
return (seed % 360).toDouble();
}
LatLng _offsetFromLocation(
LatLng origin, {
required double distanceMeters,
required double bearingDegrees,
}) {
const earthRadiusMeters = 6371000.0;
final angularDistance = distanceMeters / earthRadiusMeters;
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
final sinLat1 = sin(lat1);
final cosLat1 = cos(lat1);
final sinAngularDistance = sin(angularDistance);
final cosAngularDistance = cos(angularDistance);
final lat2 = asin(
sinLat1 * cosAngularDistance +
cosLat1 * sinAngularDistance * cos(bearingRadians),
);
final lon2 =
lon1 +
atan2(
sin(bearingRadians) * sinAngularDistance * cosLat1,
cosAngularDistance - sinLat1 * sin(lat2),
);
return LatLng(
lat2 * 180.0 / 3.1415926535897932,
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
);
}
/// Update contact telemetry /// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called'); debugPrint('📊 [ContactsProvider] updateTelemetry() called');
@@ -646,20 +941,74 @@ class ContactsProvider with ChangeNotifier {
Uint8List publicKey, { Uint8List publicKey, {
required int signedEncodedPathLen, required int signedEncodedPathLen,
required Uint8List paddedPathBytes, required Uint8List paddedPathBytes,
LatLng? inferredFallbackLocation,
}) { }) {
final contact = findContactByKey(publicKey); final contact = findContactByKey(publicKey);
if (contact == null) { if (contact == null) {
return; return;
} }
_contacts[contact.publicKeyHex] = contact.copyWith( var updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen, outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes), outPath: Uint8List.fromList(paddedPathBytes),
); );
if (inferredFallbackLocation != null) {
updatedContact = updatedContact
.copyWith(
advLat: _coordinateToAdvertMicrodegrees(
inferredFallbackLocation.latitude,
),
advLon: _coordinateToAdvertMicrodegrees(
inferredFallbackLocation.longitude,
),
)
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
}
_contacts[contact.publicKeyHex] = updatedContact;
_persistContacts(); _persistContacts();
notifyListeners(); notifyListeners();
} }
void retainReceivedRoute(
Uint8List publicKey, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
Uint8List? devicePublicKey,
}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final contact = _contacts[keyHex];
if (contact != null) {
_contacts[keyHex] = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
_persistContacts();
notifyListeners();
return;
}
final now = DateTime.now();
final existing = _pendingAdverts[keyHex];
_pendingAdverts[keyHex] =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
))
.copyWith(
receivedAt: now,
signedEncodedPathLen: signedEncodedPathLen,
paddedPathBytes: Uint8List.fromList(paddedPathBytes),
);
notifyListeners();
}
void resetContactRouteLocal(Uint8List publicKey) { void resetContactRouteLocal(Uint8List publicKey) {
final contact = findContactByKey(publicKey); final contact = findContactByKey(publicKey);
if (contact == null) { if (contact == null) {

View File

@@ -86,6 +86,7 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})? Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback; onDirectPathFailedCallback;
void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({ Future<bool> Function({
required String messageId, required String messageId,
required Contact contact, required Contact contact,
@@ -165,8 +166,12 @@ class MessagesProvider with ChangeNotifier {
final index = _messages.indexWhere((message) => message.id == messageId); final index = _messages.indexWhere((message) => message.id == messageId);
if (index != -1) { if (index != -1) {
final nextPathLen = selection.hopCount > 0
? selection.hopCount
: _messages[index].pathLen;
_messages[index] = _messages[index].copyWith( _messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood, usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen,
); );
} }
@@ -2069,6 +2074,7 @@ class MessagesProvider with ChangeNotifier {
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId);
_messages[index] = Message( _messages[index] = Message(
id: message.id, id: message.id,

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
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';
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
); );
try { try {
final pcm = await _codec.decodePackets(session.packets, session.mode); final decodedPcm = await _codec.decodePackets(
session.packets,
session.mode,
);
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples'); debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId; _playingSessionId = sessionId;
notifyListeners(); notifyListeners();
await _player.play(pcm); await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) { if (_playingSessionId == sessionId) {
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
return pcm;
}
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); _sessions.clear();
_outgoingSessions.clear(); _outgoingSessions.clear();

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
@@ -32,6 +33,14 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingPendingBatch = false;
final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '',
ContactSection.repeaters: '',
ContactSection.rooms: '',
ContactSection.channels: '',
};
late final Map<ContactSection, TextEditingController> _filterControllers;
final Map<ContactSection, ContactSortMode> _sortModes = { final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen,
@@ -41,6 +50,10 @@ class _ContactsTabState extends State<ContactsTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filterControllers = {
for (final section in ContactSection.values)
section: TextEditingController(text: _sectionFilters[section] ?? ''),
};
_getCurrentLocation(); _getCurrentLocation();
// Mark all contacts as viewed when tab is opened // Mark all contacts as viewed when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -48,6 +61,14 @@ class _ContactsTabState extends State<ContactsTab> {
}); });
} }
@override
void dispose() {
for (final controller in _filterControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _getCurrentLocation() async { Future<void> _getCurrentLocation() async {
try { try {
final position = await Geolocator.getCurrentPosition( final position = await Geolocator.getCurrentPosition(
@@ -95,6 +116,38 @@ class _ContactsTabState extends State<ContactsTab> {
} }
} }
void _schedulePendingAdvertResolution(
List<PendingAdvert> pendingAdverts,
ConnectionProvider connectionProvider,
) {
if (_isResolvingPendingBatch ||
!connectionProvider.deviceInfo.isConnected ||
pendingAdverts.isEmpty) {
return;
}
final advertsToResolve = pendingAdverts
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
.toList();
if (advertsToResolve.isEmpty) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted || _isResolvingPendingBatch) return;
_isResolvingPendingBatch = true;
try {
for (final advert in advertsToResolve) {
if (!mounted) break;
await _handleResolveAdvert(advert);
}
} finally {
_isResolvingPendingBatch = false;
}
});
}
/// Calculate distance between two points in meters /// Calculate distance between two points in meters
double _calculateDistanceInMeters( double _calculateDistanceInMeters(
double lat1, double lat1,
@@ -135,6 +188,96 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
List<Contact> _filterContactsForSection(
List<Contact> contacts,
ContactSection section,
) {
final query = (_sectionFilters[section] ?? '').trim().toLowerCase();
if (query.isEmpty) {
return contacts;
}
return contacts.where((contact) {
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(query) || advertisedName.contains(query);
}).toList();
}
bool _contactMatchesFilter(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return true;
}
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(normalizedQuery) ||
advertisedName.contains(normalizedQuery);
}
List<_RenderedSavedGroup> _buildSavedGroupsForSection(
ContactsProvider contactsProvider,
List<Contact> contacts,
ContactSection section,
) {
return contactsProvider
.savedGroupsForSection(section.name)
.map((group) {
final matches = contacts
.where((contact) => _contactMatchesFilter(contact, group.query))
.toList();
return _RenderedSavedGroup(group: group, contacts: matches);
})
.where((group) => group.contacts.isNotEmpty)
.toList()
..sort(
(a, b) => b.contacts.first.lastSeenTime.compareTo(
a.contacts.first.lastSeenTime,
),
);
}
Future<void> _toggleSavedGroupForSection(
BuildContext context,
ContactsProvider contactsProvider,
ContactSection section,
) async {
final filter = (_sectionFilters[section] ?? '').trim();
if (filter.isEmpty) {
return;
}
final alreadySaved = contactsProvider.hasSavedGroupForFilter(
section.name,
filter,
);
if (alreadySaved) {
await contactsProvider.removeSavedGroupForFilter(section.name, filter);
} else {
await contactsProvider.addSavedGroupForFilter(
section.name,
filter,
label: filter,
);
}
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
alreadySaved
? 'Removed saved group "$filter"'
: 'Saved group "$filter"',
),
),
);
}
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) { List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts); final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) { if (section == ContactSection.channels) {
@@ -230,30 +373,69 @@ class _ContactsTabState extends State<ContactsTab> {
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final chatContacts = _sortContacts( final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers, ContactSection.teamMembers,
); );
final repeaters = _sortContacts( final allRepeaters = _sortContacts(
contactsProvider.repeaters, contactsProvider.repeaters,
ContactSection.repeaters, ContactSection.repeaters,
); );
final rooms = _sortContacts( final allRooms = _sortContacts(
contactsProvider.rooms, contactsProvider.rooms,
ContactSection.rooms, ContactSection.rooms,
); );
final channels = _sortContacts( final allChannels = _sortContacts(
contactsProvider.channels, contactsProvider.channels,
ContactSection.channels, ContactSection.channels,
); );
final chatContacts = _filterContactsForSection(
allChatContacts,
ContactSection.teamMembers,
);
final savedTeamGroups = _buildSavedGroupsForSection(
contactsProvider,
allChatContacts,
ContactSection.teamMembers,
);
final repeaters = _filterContactsForSection(
allRepeaters,
ContactSection.repeaters,
);
final savedRepeaterGroups = _buildSavedGroupsForSection(
contactsProvider,
allRepeaters,
ContactSection.repeaters,
);
final rooms = _filterContactsForSection(
allRooms,
ContactSection.rooms,
);
final savedRoomGroups = _buildSavedGroupsForSection(
contactsProvider,
allRooms,
ContactSection.rooms,
);
final filteredChannels = _filterContactsForSection(
allChannels,
ContactSection.channels,
);
final savedChannelGroups = _buildSavedGroupsForSection(
contactsProvider,
allChannels,
ContactSection.channels,
);
final pendingAdverts = contactsProvider.pendingAdverts; final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
// Check if there are any displayable contacts // Check if there are any displayable contacts
final hasDisplayableContacts = final hasDisplayableContacts =
chatContacts.isNotEmpty || allChatContacts.isNotEmpty ||
repeaters.isNotEmpty || allRepeaters.isNotEmpty ||
rooms.isNotEmpty || allRooms.isNotEmpty ||
channels.isNotEmpty || allChannels.isNotEmpty ||
pendingAdverts.isNotEmpty; pendingAdverts.isNotEmpty;
if (!hasDisplayableContacts) { if (!hasDisplayableContacts) {
@@ -287,29 +469,8 @@ class _ContactsTabState extends State<ContactsTab> {
child: ListView( child: ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
children: [ children: [
// Pending adverts (public key only; quick resolve)
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_add_alt_1,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.publicKeyHex}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
),
),
const Divider(height: 32),
],
// Team Members (Chat contacts) // Team Members (Chat contacts)
if (chatContacts.isNotEmpty) ...[ if (allChatContacts.isNotEmpty) ...[
_SectionHeader( _SectionHeader(
title: l10n.teamMembers, title: l10n.teamMembers,
count: chatContacts.length, count: chatContacts.length,
@@ -319,42 +480,122 @@ class _ContactsTabState extends State<ContactsTab> {
ContactSection.teamMembers, ContactSection.teamMembers,
), ),
), ),
..._buildContactSectionItems(chatContacts), _buildSectionFilterField(
context,
ContactSection.teamMembers,
contactsProvider,
),
if (chatContacts.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedTeamGroups,
ContactSection.teamMembers,
),
..._buildContactSectionItems(
_excludeGroupedContacts(chatContacts, savedTeamGroups),
),
],
const Divider(height: 32), const Divider(height: 32),
], ],
// Repeaters // Repeaters
if (repeaters.isNotEmpty) ...[ if (allRepeaters.isNotEmpty) ...[
_SectionHeader( _SectionHeader(
title: l10n.repeaters, title: l10n.repeaters,
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters), trailing: _buildSortMenu(context, ContactSection.repeaters),
), ),
..._buildContactSectionItems(repeaters), _buildSectionFilterField(
context,
ContactSection.repeaters,
contactsProvider,
),
if (repeaters.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedRepeaterGroups,
ContactSection.repeaters,
),
..._buildContactSectionItems(
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
),
],
const Divider(height: 32), const Divider(height: 32),
], ],
// Rooms // Rooms
if (rooms.isNotEmpty) ...[ if (allRooms.isNotEmpty) ...[
_SectionHeader( _SectionHeader(
title: l10n.rooms, title: l10n.rooms,
count: rooms.length, count: rooms.length,
icon: Icons.tag, icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms), trailing: _buildSortMenu(context, ContactSection.rooms),
), ),
..._buildContactSectionItems(rooms), _buildSectionFilterField(
context,
ContactSection.rooms,
contactsProvider,
),
if (rooms.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedRoomGroups,
ContactSection.rooms,
),
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, savedRoomGroups),
),
],
const Divider(height: 32),
],
// Pending adverts are kept below resolved sections while we load details.
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_search,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
// Channels (visible in both simple and advanced mode) // Channels (visible in both simple and advanced mode)
_SectionHeader( _SectionHeader(
title: l10n.channels, title: l10n.channels,
count: channels.length, count: filteredChannels.length,
icon: Icons.broadcast_on_personal, icon: Icons.broadcast_on_personal,
), ),
if (channels.isNotEmpty) ...[ _buildSectionFilterField(
...channels.map( context,
ContactSection.channels,
contactsProvider,
),
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
_buildEmptyFilterState(context),
..._buildSavedGroupCards(
savedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isNotEmpty) ...[
..._excludeGroupedContacts(
filteredChannels,
savedChannelGroups,
).map(
(channel) => _ChannelActivityCard( (channel) => _ChannelActivityCard(
channel: channel, channel: channel,
messagesProvider: messagesProvider, messagesProvider: messagesProvider,
@@ -418,6 +659,209 @@ class _ContactsTabState extends State<ContactsTab> {
}).toList(); }).toList();
} }
List<Contact> _excludeGroupedContacts(
List<Contact> contacts,
List<_RenderedSavedGroup> savedGroups,
) {
final groupedKeys = savedGroups
.expand(
(group) => group.contacts.map((contact) => contact.publicKeyHex),
)
.toSet();
return contacts
.where((contact) => !groupedKeys.contains(contact.publicKeyHex))
.toList();
}
List<Widget> _buildSavedGroupCards(
List<_RenderedSavedGroup> groups,
ContactSection section,
) {
return groups
.map(
(group) => _InferredContactGroupCard(
label: group.group.label,
contacts: group.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
onDelete: () => context
.read<ContactsProvider>()
.removeSavedGroupById(group.group.id),
kindLabel: 'Saved filter',
),
)
.toList();
}
Widget _buildSectionFilterField(
BuildContext context,
ContactSection section,
ContactsProvider contactsProvider,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final controller = _filterControllers[section]!;
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
final isSavedFilter = hasFilter
? contactsProvider.hasSavedGroupForFilter(
section.name,
_sectionFilters[section] ?? '',
)
: false;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.transparent,
child: Ink(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasFilter
? colorScheme.primary.withValues(alpha: 0.38)
: colorScheme.outline.withValues(alpha: 0.32),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.025),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 42,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8),
child: Icon(
Icons.search_rounded,
size: 17,
color: hasFilter
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: TextFormField(
controller: controller,
onChanged: (value) {
setState(() {
_sectionFilters[section] = value;
});
},
cursorColor: colorScheme.primary,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.1,
),
decoration: InputDecoration(
hintText: 'Search this section',
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.85,
),
),
filled: true,
fillColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
),
),
),
),
if (hasFilter) ...[
Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
color:
(isSavedFilter
? colorScheme.error
: colorScheme.primary)
.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _toggleSavedGroupForSection(
context,
contactsProvider,
section,
),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
isSavedFilter
? Icons.delete_outline_rounded
: Icons.bookmark_add_outlined,
size: 16,
color: isSavedFilter
? colorScheme.error
: colorScheme.primary,
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
color: colorScheme.primary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
controller.clear();
setState(() {
_sectionFilters[section] = '';
});
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: 14,
color: colorScheme.primary,
),
),
),
),
),
] else
const SizedBox(width: 12),
],
),
),
),
),
),
);
}
Widget _buildEmptyFilterState(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'No matches for this filter.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildSortMenu(BuildContext context, ContactSection section) { Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen; final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
@@ -513,7 +957,7 @@ class _PendingAdvertTile extends StatelessWidget {
) )
: IconButton( : IconButton(
icon: const Icon(Icons.person_add_alt_1), icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Quick add', tooltip: 'Resolve contact',
onPressed: onResolve, onPressed: onResolve,
), ),
), ),
@@ -521,6 +965,13 @@ class _PendingAdvertTile extends StatelessWidget {
} }
} }
class _RenderedSavedGroup {
final SavedContactGroup group;
final List<Contact> contacts;
const _RenderedSavedGroup({required this.group, required this.contacts});
}
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
final String title; final String title;
final int count; final int count;
@@ -570,20 +1021,24 @@ class _SectionHeader extends StatelessWidget {
class _InferredContactGroupCard extends StatelessWidget { class _InferredContactGroupCard extends StatelessWidget {
final String label; final String label;
final List<Contact> contacts; final List<Contact> contacts;
final String? kindLabel;
final Position? currentPosition; final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance; final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance; final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages; final VoidCallback? onNavigateToMessages;
final VoidCallback? onDelete;
const _InferredContactGroupCard({ const _InferredContactGroupCard({
required this.label, required this.label,
required this.contacts, required this.contacts,
this.kindLabel,
required this.currentPosition, required this.currentPosition,
required this.calculateDistance, required this.calculateDistance,
required this.formatDistance, required this.formatDistance,
required this.onNavigateToMap, required this.onNavigateToMap,
required this.onNavigateToMessages, required this.onNavigateToMessages,
this.onDelete,
}); });
@override @override
@@ -613,11 +1068,24 @@ class _InferredContactGroupCard extends StatelessWidget {
title: Row( title: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Column(
label, crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of( children: [
context, Text(
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (kindLabel case final value?)
Text(
value,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -632,12 +1100,25 @@ class _InferredContactGroupCard extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
), ),
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Delete group',
onPressed: onDelete,
icon: Icon(
Icons.delete_outline_rounded,
size: 18,
color: colorScheme.error,
),
),
],
], ],
), ),
children: [ children: [
...contacts.map( ...contacts.map(
(contact) => ContactTile( (contact) => ContactTile(
contact: contact, contact: contact,
groupLabel: label,
currentPosition: currentPosition, currentPosition: currentPosition,
calculateDistance: calculateDistance, calculateDistance: calculateDistance,
formatDistance: formatDistance, formatDistance: formatDistance,

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import 'repeaters_map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart'; import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -218,6 +219,10 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state; _lifecycleState = state;
@@ -647,6 +652,41 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: const Row( child: const Row(
@@ -1056,6 +1096,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12), SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators) if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
context, context,

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const int _maxVoicePackets = 10; static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -88,13 +88,13 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount); _textController.addListener(_updateCharacterCount);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -817,7 +817,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately. // Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate(); final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -856,11 +856,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate, _selectedVoiceBitrate,
); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -869,9 +870,13 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -882,7 +887,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -899,6 +904,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -913,9 +924,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -1104,6 +1120,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1133,16 +1156,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0; var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
}
} }
return peak;
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {

View File

@@ -18,7 +18,6 @@ import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
@@ -88,7 +87,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
@@ -178,7 +177,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool('show_rx_tx_indicators', value);
} }
Future<void> _loadVoiceBitratePreference() async { Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate(); final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -1164,6 +1163,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
@@ -1210,6 +1214,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut), secondary: const Icon(Icons.content_cut),
@@ -1729,6 +1770,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled, required bool silenceTrimEnabled,
}) { }) {
final supported = VoiceBitratePreferences.supportedBitrates; final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1741,6 +1785,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) + (bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0); (silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1748,7 +1795,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw); final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration( final voiceDirect = estimateVoiceTransmitDuration(
@@ -1783,7 +1830,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Bitrate: $bitrate bps', 'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -1830,6 +1877,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
enabled: noiseSuppressionEnabled,
),
),
],
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1842,7 +1914,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Processing enabled: $enabledCount/4', 'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -0,0 +1,336 @@
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
class ResolvedContactRoutePlan {
final List<String> tokens;
final List<Contact> selectedContacts;
final String summary;
const ResolvedContactRoutePlan({
required this.tokens,
required this.selectedContacts,
required this.summary,
});
String get canonicalText => tokens.join(',');
}
class ContactRouteResolver {
static const Distance _distance = Distance();
const ContactRouteResolver._();
static ResolvedContactRoutePlan? resolveAutomaticRoute({
required LatLng senderLocation,
required Contact recipient,
required List<Contact> availableContacts,
required int hashSize,
}) {
final recipientLocation = recipient.displayLocation;
if (recipientLocation == null) return null;
final repeaters = availableContacts
.where(
(contact) =>
contact.isRepeater &&
contact.displayLocation != null &&
contact.publicKeyHex != recipient.publicKeyHex,
)
.toList();
if (repeaters.isEmpty) return null;
final recipientLatLng = LatLng(
recipientLocation.latitude,
recipientLocation.longitude,
);
final routedCandidates =
repeaters
.where(
(contact) =>
contact.routeHasPath && contact.routeHashSize == hashSize,
)
.toList()
..sort(
(a, b) =>
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
availableContacts: repeaters,
hashSize: hashSize,
).compareTo(
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
availableContacts: repeaters,
hashSize: hashSize,
),
),
);
if (routedCandidates.isNotEmpty) {
final anchor = routedCandidates.first;
final tokens = <String>[
...anchor.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toUpperCase()),
_tokenFor(anchor, hashSize),
];
final selectedContacts = _matchContactsForTokens(
tokens,
availableContacts: repeaters,
);
return ResolvedContactRoutePlan(
tokens: _dedupeTokens(tokens),
selectedContacts: selectedContacts,
summary: 'Resolved via known repeater route',
);
}
final corridorRepeaters = List<Contact>.from(repeaters)
..sort((a, b) {
final progressA = _progressAlongSegment(
point: LatLng(
a.displayLocation!.latitude,
a.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressB = _progressAlongSegment(
point: LatLng(
b.displayLocation!.latitude,
b.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressCompare = progressA.compareTo(progressB);
if (progressCompare != 0) return progressCompare;
return _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
).compareTo(
_scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
),
);
});
final selected = <Contact>[];
var currentPoint = senderLocation;
var currentDistanceToRecipient = _distance.as(
LengthUnit.Meter,
senderLocation,
recipientLatLng,
);
final maxCorridorDistance = math.max(
1500.0,
currentDistanceToRecipient * 0.22,
);
for (final repeater in corridorRepeaters) {
if (selected.length >= 4) break;
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final distanceToSegment = _distanceToSegmentMeters(
repeaterPoint,
senderLocation,
recipientLatLng,
);
if (distanceToSegment > maxCorridorDistance) {
continue;
}
final nextDistanceToRecipient = _distance.as(
LengthUnit.Meter,
repeaterPoint,
recipientLatLng,
);
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
continue;
}
final distanceFromCurrent = _distance.as(
LengthUnit.Meter,
currentPoint,
repeaterPoint,
);
if (distanceFromCurrent < 100) {
continue;
}
selected.add(repeater);
currentPoint = repeaterPoint;
currentDistanceToRecipient = nextDistanceToRecipient;
if (currentDistanceToRecipient < 2500) {
break;
}
}
if (selected.isEmpty) {
return null;
}
return ResolvedContactRoutePlan(
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
selectedContacts: selected,
summary: 'Resolved from repeater locations',
);
}
static List<Contact> _matchContactsForTokens(
List<String> tokens, {
required List<Contact> availableContacts,
}) {
final matches = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = availableContacts
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
matches.add(match);
}
}
return matches;
}
static List<String> _dedupeTokens(List<String> tokens) {
final result = <String>[];
for (final token in tokens) {
if (result.isEmpty || result.last != token) {
result.add(token);
}
}
return result;
}
static String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
return hex.length < length ? hex : hex.substring(0, length);
}
static double _scoreRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
}) {
final point = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final toRecipient = _distance.as(
LengthUnit.Meter,
point,
recipientLocation,
);
final corridor = _distanceToSegmentMeters(
point,
senderLocation,
recipientLocation,
);
return (toRecipient * 0.75) + (corridor * 0.25);
}
static double _scoreKnownRouteRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
required List<Contact> availableContacts,
required int hashSize,
}) {
final baseScore = _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLocation,
repeater: repeater,
);
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final chainContacts = repeater.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map(
(token) => availableContacts
.where(
(contact) =>
contact.displayLocation != null &&
contact.publicKeyHex.toUpperCase().startsWith(
token.toUpperCase(),
),
)
.firstOrNull,
)
.whereType<Contact>()
.toList();
final chainEnd = chainContacts.isNotEmpty
? LatLng(
chainContacts.last.displayLocation!.latitude,
chainContacts.last.displayLocation!.longitude,
)
: senderLocation;
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
final senderGap = _distance.as(
LengthUnit.Meter,
senderLocation,
repeaterPoint,
);
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
}
static double _progressAlongSegment({
required LatLng point,
required LatLng start,
required LatLng end,
}) {
final dx = end.longitude - start.longitude;
final dy = end.latitude - start.latitude;
final lengthSquared = (dx * dx) + (dy * dy);
if (lengthSquared == 0) return 0;
return (((point.longitude - start.longitude) * dx) +
((point.latitude - start.latitude) * dy)) /
lengthSquared;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -2,12 +2,14 @@ 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 '../models/contact_group.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage /// Service for persisting contacts to local storage
class ContactStorageService { class ContactStorageService {
static const String _contactsKey = 'stored_contacts'; static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const int _maxStoredContacts = 500; // Store up to 500 contacts static const int _maxStoredContacts = 500; // Store up to 500 contacts
/// Save contacts to persistent storage /// Save contacts to persistent storage
@@ -90,6 +92,40 @@ class ContactStorageService {
} }
} }
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -203,4 +239,31 @@ class ContactStorageService {
return null; return null;
} }
} }
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
} }

View File

@@ -0,0 +1,246 @@
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
String get payloadLabel {
final decodedRoute = route;
if (decodedRoute == null) {
return log.responseCode != null ? log.opcodeName : 'Unknown';
}
return payloadTypeLabel(decodedRoute.payloadType);
}
String? get payloadMeaning {
final decodedRoute = route;
if (decodedRoute == null) return null;
return payloadTypeMeaning(decodedRoute.payloadType);
}
String get routePreview {
final decodedRoute = route;
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
return 'Direct packet';
}
return decodedRoute.hopHashes
.map((hashHex) => '0x${hashHex.toUpperCase()}')
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
}
}
class LiveTrafficSnapshot {
final DateTime windowStart;
final Duration windowDuration;
final int packetsPerMinute;
final int rxCount;
final int txCount;
final int totalCount;
final double? avgSnrDb;
final double? latestSnrDb;
final double? avgRssiDbm;
final int? latestRssiDbm;
final int multiHopCount;
final double? avgHopCount;
final List<LiveTrafficEntry> visibleEntries;
final LiveTrafficBusyness busyness;
const LiveTrafficSnapshot({
required this.windowStart,
required this.windowDuration,
required this.packetsPerMinute,
required this.rxCount,
required this.txCount,
required this.totalCount,
required this.avgSnrDb,
required this.latestSnrDb,
required this.avgRssiDbm,
required this.latestRssiDbm,
required this.multiHopCount,
required this.avgHopCount,
required this.visibleEntries,
required this.busyness,
});
}
class LiveTrafficSummary {
static const Duration rollingWindow = Duration(seconds: 60);
static const int maxVisibleEntries = 120;
static const int logRxDataResponseCode = 0x88;
const LiveTrafficSummary._();
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
DateTime? clearedAt,
int? preferredHashSize,
Duration window = rollingWindow,
String? packetTypeFilter,
}) {
final windowStart = now.subtract(window);
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
final route = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: preferredHashSize,
);
entries.add(LiveTrafficEntry(log: log, route: route));
}
final filteredEntries = packetTypeFilter == null
? entries
: entries
.where((entry) => entry.payloadLabel == packetTypeFilter)
.toList();
var rxCount = 0;
var snrCount = 0;
var snrSum = 0.0;
var rssiCount = 0;
var rssiSum = 0.0;
double? latestSnrDb;
int? latestRssiDbm;
var multiHopCount = 0;
var hopCountTotal = 0;
var hopCountSamples = 0;
for (final entry in filteredEntries) {
rxCount += 1;
final rxInfo = entry.log.logRxDataInfo;
if (rxInfo?.snrDb != null) {
snrCount += 1;
snrSum += rxInfo!.snrDb!;
latestSnrDb = rxInfo.snrDb!;
}
if (rxInfo?.rssiDbm != null) {
rssiCount += 1;
rssiSum += rxInfo!.rssiDbm!.toDouble();
latestRssiDbm = rxInfo.rssiDbm!;
}
final route = entry.route;
if (route != null && route.hopCount > 0) {
hopCountSamples += 1;
hopCountTotal += route.hopCount;
if (route.hopCount > 1) {
multiHopCount += 1;
}
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute = totalCount;
return LiveTrafficSnapshot(
windowStart: effectiveStart,
windowDuration: window,
packetsPerMinute: packetsPerMinute,
rxCount: rxCount,
txCount: txCount,
totalCount: totalCount,
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
latestSnrDb: latestSnrDb,
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);
}
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
if (ppm <= 5) return LiveTrafficBusyness.quiet;
if (ppm <= 20) return LiveTrafficBusyness.active;
return LiveTrafficBusyness.busy;
}
}

View File

@@ -19,6 +19,8 @@ class MessageStorageService {
'stored_message_transfer_details'; 'stored_message_transfer_details';
static const String _messageRouteMetadataKey = static const String _messageRouteMetadataKey =
'stored_message_route_metadata'; 'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
@@ -32,8 +34,16 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON // Convert messages to JSON and embed path bytes as a fallback so they
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); // survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent) // Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages final limitedList = jsonList.length > _maxStoredMessages
@@ -129,24 +139,36 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) { if (jsonString != null && jsonString.isNotEmpty) {
final value = entry.value; final decoded = jsonDecode(jsonString);
if (value is! Map<String, dynamic>) continue; if (decoded is Map<String, dynamic>) {
final snapshot = MessageReceptionDetails.fromJson(value); for (final entry in decoded.entries) {
if (snapshot != null) { final value = entry.value;
result[entry.key] = snapshot; if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
} }
} }
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result; return result;
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e'); debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -278,7 +300,10 @@ class MessageStorageService {
} }
/// Convert Message to JSON /// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) { Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
return { return {
'id': message.id, 'id': message.id,
'messageType': message.messageType.name, 'messageType': message.messageType.name,
@@ -338,9 +363,67 @@ class MessageStorageService {
}, },
) )
.toList(), .toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
}; };
} }
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message /// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) { Message? _messageFromJson(Map<String, dynamic> json) {
try { try {

View File

@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v1';
@@ -53,6 +54,7 @@ class PathHistoryService {
pathBytes: contact.routePathBytes.toList(), pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount, hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize, hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -83,15 +85,21 @@ class PathHistoryService {
return; return;
} }
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0')) .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final existing = _findDirectPath(history.directPaths, signature); final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord( final updated = PathRecord(
pathBytes: List<int>.from(pathBytes), pathBytes: normalizedPathBytes,
hopCount: pathBytes.length ~/ hashSize, hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize, hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -191,6 +199,7 @@ class PathHistoryService {
pathBytes: selection.pathBytes.toList(), pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0), successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1), failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success lastRoundTripTimeMs: success

View File

@@ -0,0 +1,21 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/voice_message_parser.dart';
class VoiceCodecPreferences {
static const String _codecKey = 'voice_codec';
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
static Future<VoiceCodecKind> getCodec() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_codecKey);
return VoiceCodecKind.values.firstWhere(
(codec) => codec.name == raw,
orElse: () => defaultCodec,
);
}
static Future<void> setCodec(VoiceCodecKind codec) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_codecKey, codec.name);
}
}

View File

@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -39,14 +43,11 @@ class VoiceCodecService {
} }
} }
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) { Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
} }
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) { Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
@@ -59,24 +60,14 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[]; final all = <Int16List>[];
for (final pkt in packets) { for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) { if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode all.add(Int16List(mode.samplesPerPacket));
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else { } else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); all.add(await decode(pkt.codec2Data, mode));
all.add(decoded);
} }
} }
final total = all.fold<int>(0, (sum, l) => sum + l.length); final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total); final result = Int16List(total);
var offset = 0; var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode { enum Codec2Mode {
mode3200(0), mode3200(0),
mode2400(1), mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond { int get bytesPerSecond {
switch (this) { switch (this) {
case mode3200: return 400; case mode3200:
case mode700c: return 100; return 400;
case mode1200: return 150; case mode700c:
case mode1300: return 175; return 100;
case mode1400: return 175; case mode1200:
case mode1600: return 200; return 150;
case mode2400: return 300; case mode1300:
return 175;
case mode1400:
return 175;
case mode1600:
return 200;
case mode2400:
return 300;
} }
} }
@@ -40,13 +46,20 @@ enum Codec2Mode {
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -66,6 +79,5 @@ class VoiceCodecService {
Future<Int16List> decodePackets( Future<Int16List> decodePackets(
List<VoicePacket?> packets, List<VoicePacket?> packets,
VoicePacketMode mode, VoicePacketMode mode,
) => ) => Future.error(UnsupportedError('Voice not supported on web'));
Future.error(UnsupportedError('Voice not supported on web'));
} }

View File

@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file /// Plays decoded mono PCM samples by writing a WAV file
/// to the system temp directory and using [AudioPlayer]. /// to the system temp directory and using [AudioPlayer].
class VoicePlayerService { class VoicePlayerService {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
}); });
} }
/// Play [pcmSamples] (Int16, 8000 Hz, mono). Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples'); debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop(); if (_isPlaying) await stop();
_position = Duration.zero; _position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000); _duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now(); _playbackStartedAt = DateTime.now();
_events.add(null); _events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory(); final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav'); final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes); await file.writeAsBytes(wavBytes);

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
/// Captures raw PCM audio at 8000 Hz, 16-bit mono. /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
/// ///
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM /// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
/// samples every [chunkDuration]. Call [stopCapture] to end recording. /// samples every [chunkDuration]. Call [stopCapture] to end recording.
@@ -27,12 +27,16 @@ class VoiceRecorderService {
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true. /// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics before encoding. /// [enableCompressor] normalizes speech dynamics before encoding.
/// [enableLimiter] protects against clipping peaks before encoding. /// [enableLimiter] protects against clipping peaks before encoding.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding. /// The returned stream emits [Int16List] chunks that are ready for voice encoding.
Stream<Int16List> startCapture({ Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1), Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000,
bool enableBandPassFilter = true, bool enableBandPassFilter = true,
bool enableCompressor = true, bool enableCompressor = true,
bool enableLimiter = true, bool enableLimiter = true,
bool enableAutoGain = false,
bool enableEchoCancellation = false,
bool enableNoiseSuppression = false,
}) { }) {
if (_isRecording) { if (_isRecording) {
throw StateError('VoiceRecorderService: already recording'); throw StateError('VoiceRecorderService: already recording');
@@ -43,39 +47,59 @@ class VoiceRecorderService {
_startRecording( _startRecording(
chunkDuration, chunkDuration,
sampleRateHz: sampleRateHz,
enableBandPassFilter: enableBandPassFilter, enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
enableAutoGain: enableAutoGain,
enableEchoCancellation: enableEchoCancellation,
enableNoiseSuppression: enableNoiseSuppression,
); );
return _controller!.stream; return _controller!.stream;
} }
Future<void> _startRecording( Future<void> _startRecording(
Duration chunkDuration, { Duration chunkDuration, {
required int sampleRateHz,
required bool enableBandPassFilter, required bool enableBandPassFilter,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
required bool enableAutoGain,
required bool enableEchoCancellation,
required bool enableNoiseSuppression,
}) async { }) async {
final config = const RecordConfig( final useBandPassFilter = enableBandPassFilter;
final useCompressor = enableCompressor;
final useLimiter = enableLimiter;
final config = RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: 8000, sampleRate: sampleRateHz,
numChannels: 1, numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API bitRate: 128000, // ignored for PCM, but required by API
autoGain: enableAutoGain,
echoCancel: enableEchoCancellation,
noiseSuppress: enableNoiseSuppression,
); );
try { try {
final stream = await _recorder.startStream(config); final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter( final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000, sampleRate: sampleRateHz,
lowCutHz: 250.0, lowCutHz: 250.0,
highCutHz: 3400.0, highCutHz: 3400.0,
); );
final dynamics = _VoiceDynamicsProcessor( final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000, sampleRate: sampleRateHz,
enableCompressor: enableCompressor, thresholdDb: -18.0,
enableLimiter: enableLimiter, ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
enableCompressor: useCompressor,
enableLimiter: useLimiter,
); );
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000; final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[]; final buffer = <int>[];
_sub = stream.listen( _sub = stream.listen(
@@ -85,9 +109,7 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes); final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes); buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk)); final pcm = _bytesToInt16(Uint8List.fromList(chunk));
final filtered = enableBandPassFilter final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered)); _controller?.add(dynamics.process(filtered));
} }
}, },
@@ -95,9 +117,7 @@ class VoiceRecorderService {
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
final padded = _padToEven(buffer); final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded)); final pcm = _bytesToInt16(Uint8List.fromList(padded));
final filtered = enableBandPassFilter final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered)); _controller?.add(dynamics.process(filtered));
} }
_controller?.close(); _controller?.close();
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
_VoiceDynamicsProcessor({ _VoiceDynamicsProcessor({
required int sampleRate, required int sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
}) : _enableCompressor = enableCompressor, }) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter, _enableLimiter = enableLimiter,
_compressor = _SimpleCompressor( _compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0, thresholdDb: thresholdDb,
ratio: 2.5, ratio: ratio,
attackMs: 8.0, attackMs: attackMs,
releaseMs: 120.0, releaseMs: releaseMs,
makeupGainDb: 4.0, makeupGainDb: makeupGainDb,
), ),
_limiter = _PeakLimiter(ceilingDb: -1.0); _limiter = _PeakLimiter(ceilingDb: -1.0);

View File

@@ -30,6 +30,8 @@ class ResolvedNodeHash {
final bool isOwnNode; final bool isOwnNode;
final bool isUniqueMatch; final bool isUniqueMatch;
final int matchCount; final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({ const ResolvedNodeHash({
required this.hashHex, required this.hashHex,
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
required this.isOwnNode, required this.isOwnNode,
required this.isUniqueMatch, required this.isUniqueMatch,
required this.matchCount, required this.matchCount,
this.latitude,
this.longitude,
}); });
String get hexLabel => '0x${hashHex.toUpperCase()}'; String get hexLabel => '0x${hashHex.toUpperCase()}';
@@ -154,11 +158,33 @@ class LogRxRouteDecoder {
return hops; return hops;
} }
static List<int> reverseHopBytes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return List<int>.from(pathBytes.reversed);
}
final reversed = <int>[];
for (
var index = pathBytes.length - hashSize;
index >= 0;
index -= hashSize
) {
reversed.addAll(pathBytes.sublist(index, index + hashSize));
}
return reversed;
}
static ResolvedNodeHash resolveHash( static ResolvedNodeHash resolveHash(
String hashHex, { String hashHex, {
required Iterable<Contact> contacts, required Iterable<Contact> contacts,
Uint8List? ownPublicKey, Uint8List? ownPublicKey,
String? ownName, String? ownName,
double? ownLatitude,
double? ownLongitude,
}) { }) {
final normalizedHashHex = hashHex.toLowerCase(); final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey); final ownKeyHex = _bytesToHex(ownPublicKey);
@@ -172,6 +198,8 @@ class LogRxRouteDecoder {
isOwnNode: true, isOwnNode: true,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
); );
} }
@@ -190,12 +218,15 @@ class LogRxRouteDecoder {
} }
if (matches.length == 1) { if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash( return ResolvedNodeHash(
hashHex: normalizedHashHex, hashHex: normalizedHashHex,
label: matches.first.displayName, label: matches.first.displayName,
isOwnNode: false, isOwnNode: false,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
); );
} }

View File

@@ -0,0 +1,132 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final MeshMapNode? node;
final int matchCount;
final bool usedOnlineFallback;
const ResolvedTraceNode({
required this.node,
required this.matchCount,
required this.usedOnlineFallback,
});
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
}
class TraceNodeResolver {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
node: null,
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
node: null,
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
node: pool.first,
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
return double.maxFinite;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -11,20 +11,41 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1; const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
/// Identifies which Codec2 mode was used for a voice packet. enum VoiceCodecKind {
/// Matches the modeId byte in the text/binary packet header. codec2(0, 'Codec2');
enum VoicePacketMode {
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300'),
mode1400(4, '1400'),
mode1600(5, '1600'),
mode3200(6, '3200');
const VoicePacketMode(this.id, this.label); const VoiceCodecKind(this.id, this.label);
final int id; final int id;
final String label; final String label;
}
/// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
const VoicePacketMode(
this.id,
this.label,
this.codec,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id;
final String label;
final VoiceCodecKind codec;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere( static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id, (m) => m.id == id,
@@ -153,23 +174,21 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds. /// Estimated audio duration of this packet in milliseconds.
int get durationMs { int get durationMs {
// bytesPerSecond for each mode try {
final bps = switch (mode) { final bytesPerSecond = voiceModeBytesPerSecond(mode);
VoicePacketMode.mode700c => 100, if (bytesPerSecond <= 0) return 0;
VoicePacketMode.mode1200 => 150, return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
VoicePacketMode.mode1300 => 175, } catch (_) {
VoicePacketMode.mode1400 => 175, // Be permissive with stale or malformed persisted voice metadata.
VoicePacketMode.mode1600 => 200, return 0;
VoicePacketMode.mode2400 => 300, }
VoicePacketMode.mode3200 => 400,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
} }
@override @override
String toString() { String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]'; final suffix = total > 0
? ' ${mode.label} [$index/${total - 1}]'
: ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)'; return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
} }
} }
@@ -244,10 +263,10 @@ class VoiceEnvelope {
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
VoicePacketMode.mode700c => 100, VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150, VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode1300 => 175, VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175, VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200, VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400, VoicePacketMode.mode3200 => 400,
}; };
@@ -419,8 +438,7 @@ class VoiceFetchRequest {
this.version = 3, this.version = 3,
}); });
static bool isVoiceFetchRequestText(String text) => static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) => static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -473,9 +491,7 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
? 'missing'
: wantToken);
if (sid == null) { if (sid == null) {
return null; return null;
@@ -602,7 +618,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) { String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
} }
final value = int.parse(sessionIdHex, radix: 16); final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36); return value.toRadixString(36);
@@ -616,10 +636,7 @@ String? _decodeSessionId(String token) {
} }
String _encodeMissingIndicesCompact(List<int> indices) { String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort(); ..sort();
if (sorted.isEmpty) return ''; if (sorted.isEmpty) return '';
final chunks = <String>[]; final chunks = <String>[];
@@ -632,7 +649,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue; continue;
} }
chunks.add( chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
); );
start = curr; start = curr;
prev = curr; prev = curr;

View File

@@ -1,19 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../models/path_history.dart';
class ContactRouteDialogResult { class ContactRouteDialogResult {
final ParsedContactRoute? route; final ParsedContactRoute? route;
final bool shouldClear; final bool shouldClear;
final LatLng? inferredFallbackLocation;
const ContactRouteDialogResult._({this.route, required this.shouldClear}); const ContactRouteDialogResult._({
this.route,
required this.shouldClear,
this.inferredFallbackLocation,
});
const ContactRouteDialogResult.set(ParsedContactRoute route) const ContactRouteDialogResult.set(ParsedContactRoute route)
: this._(route: route, shouldClear: false); : this._(route: route, shouldClear: false);
const ContactRouteDialogResult.setWithFallback(
ParsedContactRoute route, {
LatLng? inferredFallbackLocation,
}) : this._(
route: route,
shouldClear: false,
inferredFallbackLocation: inferredFallbackLocation,
);
const ContactRouteDialogResult.clear() : this._(shouldClear: true); const ContactRouteDialogResult.clear() : this._(shouldClear: true);
} }
@@ -56,10 +77,14 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
final PathHistoryService _pathHistoryService = PathHistoryService();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
_RouteEntryMode _entryMode = _RouteEntryMode.map;
@override @override
void initState() { void initState() {
@@ -68,7 +93,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
text: widget.contact.routeCanonicalText, text: widget.contact.routeCanonicalText,
); );
_controller.addListener(_reparse); _controller.addListener(_reparse);
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
? _RouteEntryMode.manual
: _RouteEntryMode.map;
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -86,6 +115,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
setState(() { setState(() {
_parsedRoute = null; _parsedRoute = null;
_errorText = null; _errorText = null;
_selectedMapHops = const [];
}); });
return; return;
} }
@@ -95,9 +125,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
input, input,
expectedHashSize: _selectedHashSize, expectedHashSize: _selectedHashSize,
); );
final selectedMapHops = _mapSelectionForText(input);
setState(() { setState(() {
_parsedRoute = parsed; _parsedRoute = parsed;
_errorText = null; _errorText = null;
_selectedMapHops = selectedMapHops;
}); });
} on ContactRouteFormatException catch (error) { } on ContactRouteFormatException catch (error) {
setState(() { setState(() {
@@ -107,6 +139,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
} }
} }
List<Contact> get _routeCandidates =>
widget.availableContacts
.where(
(contact) => contact.isRepeater && contact.displayLocation != null,
)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
List<Contact> _mapSelectionForText(String text) {
final tokens = text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
final selected = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
selected.add(match);
}
}
return selected;
}
Future<void> _loadHashSizePreference() async { Future<void> _loadHashSizePreference() async {
final hashSize = await RouteHashPreferences.getHashSize(); final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return; if (!mounted) return;
@@ -116,6 +178,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_reparse(); _reparse();
} }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
}
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -125,137 +197,647 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return hex.substring(0, length); return hex.substring(0, length);
} }
void _appendHop(Contact contact) { void _syncControllerFromSelectedHops() {
final token = _tokenFor(contact, _selectedHashSize); final tokens = _selectedMapHops
final current = _controller.text.trim(); .map((contact) => _tokenFor(contact, _selectedHashSize))
_controller.text = current.isEmpty ? token : '$current,$token'; .toList();
_controller.text = tokens.join(',');
_controller.selection = TextSelection.fromPosition( _controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
} }
void _toggleHop(Contact contact) {
setState(() {
if (_selectedMapHops.any(
(item) => item.publicKeyHex == contact.publicKeyHex,
)) {
_selectedMapHops = _selectedMapHops
.where((item) => item.publicKeyHex != contact.publicKeyHex)
.toList();
} else {
_selectedMapHops = [..._selectedMapHops, contact];
}
_syncControllerFromSelectedHops();
_reparse();
});
}
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
setState(() {
_selectedMapHops = plan.selectedContacts;
_controller.text = plan.canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.map;
});
_reparse();
}
void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
setState(() {
_controller.text = canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.manual;
});
_reparse();
}
LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null
? null
: LatLng(
_selectedMapHops.last.displayLocation!.latitude,
_selectedMapHops.last.displayLocation!.longitude,
);
}
final tokens = _controller.text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
if (tokens.isEmpty) return null;
final lastToken = tokens.last;
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
)
.firstOrNull;
final location = match?.displayLocation;
if (location == null) return null;
return LatLng(location.latitude, location.longitude);
}
LatLng? _buildSyntheticFallbackLocation() {
final lastHopLocation = _resolveLastHopLocation();
if (lastHopLocation == null) return null;
final seed = widget.contact.publicKey.fold<int>(
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
(sum, byte) => sum + byte,
);
final angle = (seed % 360) * (math.pi / 180.0);
const radiusMeters = 500.0;
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
final lonDenominator =
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
final lonOffset = lonDenominator.abs() < 1e-6
? 0.0
: (radiusMeters / lonDenominator) * math.sin(angle);
return LatLng(
lastHopLocation.latitude + latOffset,
lastHopLocation.longitude + lonOffset,
);
}
void _resolvePathAutomatically() {
final connectionProvider = context.read<ConnectionProvider>();
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
final recipientLocation = widget.contact.displayLocation;
if (advLat == null ||
advLon == null ||
(advLat == 0 && advLon == 0) ||
recipientLocation == null) {
setState(() {
_errorText =
'Automatic resolve needs both your advertised location and the contact location.';
});
return;
}
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
recipient: widget.contact,
availableContacts: widget.availableContacts,
hashSize: _selectedHashSize,
);
if (plan == null) {
setState(() {
_errorText =
'Could not resolve a route from available repeater locations.';
});
return;
}
_applyResolvedPlan(plan);
}
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
for (var i = 0; i < pathBytes.length; i += hashSize) {
final hop = pathBytes.sublist(i, i + hashSize);
hops.add(
hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String _historySubtitle(PathRecord record) {
final attempts = record.successCount + record.failureCount;
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: const Text('Use'),
),
),
);
}
Widget _buildPreviewSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_parsedRoute == null
? 'Preview: enter or pick a route to validate it.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_parsedRoute != null) ...[
const SizedBox(height: 4),
SelectableText(
_parsedRoute!.canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
],
);
}
Widget _buildBuilderTab(
BuildContext context, {
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_RouteEntryMode>(
segments: const [
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.map,
icon: Icon(Icons.map_outlined),
label: Text('Map'),
),
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.manual,
icon: Icon(Icons.tune),
label: Text('Manual'),
),
],
selected: {_entryMode},
onSelectionChanged: (selection) {
setState(() {
_entryMode = selection.first;
});
},
),
const SizedBox(height: 16),
if (_entryMode == _RouteEntryMode.manual) ...[
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
] else ...[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _resolvePathAutomatically,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Resolve Path'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
'Tap repeaters on the map to build the path, then review the generated route below.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 260,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
),
child: mapPoints.length < 2
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
textAlign: TextAlign.center,
),
),
)
: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(32),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
if (routePoints.length >= 2)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: routePoints,
strokeWidth: 4,
color: Theme.of(context).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: [
...routeCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) =>
item.publicKeyHex ==
candidate.publicKeyHex,
);
return flutter_map.Marker(
point: LatLng(
candidate.displayLocation!.latitude,
candidate.displayLocation!.longitude,
),
width: 64,
height: 70,
child: GestureDetector(
onTap: () => _toggleHop(candidate),
child: _RouteMarkerDot(
label: _tokenFor(
candidate,
_selectedHashSize,
),
color: isSelected
? Theme.of(
context,
).colorScheme.primary
: Colors.blueGrey,
),
),
);
}),
],
),
],
),
),
),
),
const SizedBox(height: 12),
if (_selectedMapHops.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedMapHops.map((contact) {
return InputChip(
label: Text(contact.displayName),
onDeleted: () => _toggleHop(contact),
);
}).toList(),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
readOnly: true,
decoration: InputDecoration(
labelText: 'Generated route',
helperText: 'Switch to Manual if you want to edit the hop list.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
],
],
);
}
Widget _buildHistoryTab() {
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
if (records.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No historical paths for this contact yet.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>(); final appProvider = context.watch<AppProvider>();
final routeCandidates = final routeCandidates = _routeCandidates;
widget.availableContacts final connectionProvider = context.watch<ConnectionProvider>();
.where((contact) => contact.isRepeater || contact.isRoom) final selfPoint =
.toList() connectionProvider.deviceInfo.advLat != null &&
..sort((a, b) => a.displayName.compareTo(b.displayName)); connectionProvider.deviceInfo.advLon != null &&
!(connectionProvider.deviceInfo.advLat == 0 &&
connectionProvider.deviceInfo.advLon == 0)
? LatLng(
connectionProvider.deviceInfo.advLat! / 1e6,
connectionProvider.deviceInfo.advLon! / 1e6,
)
: null;
final recipientLocation = widget.contact.displayLocation;
final recipientPoint = recipientLocation == null
? null
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
final routePoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
..._selectedMapHops
.where((contact) => contact.displayLocation != null)
.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
...?recipientPoint == null ? null : [recipientPoint],
];
final mapPoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
...?recipientPoint == null ? null : [recipientPoint],
...routeCandidates.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
];
return FractionallySizedBox( return DefaultTabController(
heightFactor: 0.85, length: 2,
child: Padding( child: FractionallySizedBox(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), heightFactor: 0.85,
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
'Set Route for ${widget.contact.displayName}', children: [
style: Theme.of(context).textTheme.headlineSmall, Text(
), 'Set Route for ${widget.contact.displayName}',
const SizedBox(height: 16), style: Theme.of(context).textTheme.headlineSmall,
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 12), Text(
Text( 'Choose how to build the route, or reuse one from history.',
_parsedRoute == null style: Theme.of(context).textTheme.bodyMedium,
? 'Preview: enter a route to validate it.' ),
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', const SizedBox(height: 16),
style: Theme.of(context).textTheme.bodySmall, const TabBar(
), tabs: [
if (_parsedRoute != null) ...[ Tab(text: 'Build'),
const SizedBox(height: 4), Tab(text: 'History'),
SelectableText( ],
_parsedRoute!.canonicalText, ),
style: Theme.of( const SizedBox(height: 16),
context, Expanded(
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), child: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBuilderTab(
context,
routeCandidates: routeCandidates,
mapPoints: mapPoints,
routePoints: routePoints,
),
const SizedBox(height: 16),
_AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled:
appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry:
appProvider.clearPathOnMaxRetry,
),
],
),
),
SingleChildScrollView(child: _buildHistoryTab()),
],
),
),
OverflowBar(
alignment: MainAxisAlignment.spaceBetween,
spacing: 8,
overflowSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(context).pop(
ContactRouteDialogResult.setWithFallback(
_parsedRoute!,
inferredFallbackLocation:
_buildSyntheticFallbackLocation(),
),
),
child: const Text('Set Route'),
),
],
), ),
], ],
const SizedBox(height: 16), ),
_AutomationRoutingInfo( ),
isExpanded: _showRoutingInfo, ),
onToggle: () { );
setState(() { }
_showRoutingInfo = !_showRoutingInfo; }
});
}, enum _RouteEntryMode { map, manual }
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: class _RouteMarkerDot extends StatelessWidget {
appProvider.nearestRelayFallbackEnabled, final String label;
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, final Color color;
),
const SizedBox(height: 16), const _RouteMarkerDot({required this.label, required this.color});
Text(
'Pick hops from contacts', @override
style: Theme.of(context).textTheme.labelLarge, Widget build(BuildContext context) {
), return Tooltip(
const SizedBox(height: 8), message: label,
if (routeCandidates.isEmpty) child: Center(
const Text( child: Container(
'No repeater or room contacts are available for route building.', width: 26,
) height: 26,
else decoration: BoxDecoration(
Expanded( color: color,
child: ListView.builder( shape: BoxShape.circle,
itemCount: routeCandidates.length, border: Border.all(color: Colors.white, width: 2),
itemBuilder: (context, index) { ),
final candidate = routeCandidates[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
'Use ${_tokenFor(candidate, _selectedHashSize)}',
),
),
);
},
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
const Spacer(),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(
context,
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
child: const Text('Set Route'),
),
],
),
],
), ),
), ),
); );

View File

@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
@@ -20,6 +21,7 @@ import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {
final Contact contact; final Contact contact;
final String? groupLabel;
final Position? currentPosition; final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance; final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance; final String Function(double)? formatDistance;
@@ -29,6 +31,7 @@ class ContactTile extends StatelessWidget {
const ContactTile({ const ContactTile({
super.key, super.key,
required this.contact, required this.contact,
this.groupLabel,
this.currentPosition, this.currentPosition,
this.calculateDistance, this.calculateDistance,
this.formatDistance, this.formatDistance,
@@ -127,6 +130,25 @@ class ContactTile extends StatelessWidget {
final Widget subtitleWidget = Column( final Widget subtitleWidget = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (groupLabel case final label?)
_buildMetaPill(
context,
icon: Icons.folder_copy_outlined,
label: label,
),
_buildMetaPill(
context,
icon: Icons.key_outlined,
label: contact.publicKeyShort,
monospace: true,
),
],
),
if (location != null) ...[ if (location != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
_buildLocationLine( _buildLocationLine(
@@ -363,6 +385,15 @@ class ContactTile extends StatelessWidget {
_showSetRouteDialog(context, contact); _showSetRouteDialog(context, contact);
}, },
), ),
if (!contact.isChannel)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(context, contact);
},
),
if (!contact.isPublicChannel) if (!contact.isPublicChannel)
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), leading: const Icon(Icons.delete, color: Colors.red),
@@ -453,6 +484,18 @@ class ContactTile extends StatelessWidget {
); );
} }
void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => ContactTraceSheet(contact: contact),
);
}
void _showDeleteConfirmation( void _showDeleteConfirmation(
BuildContext context, BuildContext context,
Contact contact, { Contact contact, {
@@ -569,6 +612,7 @@ class ContactTile extends StatelessWidget {
contact.publicKey, contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
inferredFallbackLocation: routeResult.inferredFallbackLocation,
); );
try { try {
@@ -649,6 +693,38 @@ class ContactTile extends StatelessWidget {
); );
} }
Widget _buildMetaPill(
BuildContext context, {
required IconData icon,
required String label,
bool monospace = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
],
),
);
}
Widget _buildLocationLine( Widget _buildLocationLine(
BuildContext context, { BuildContext context, {
required double latitude, required double latitude,

View File

@@ -0,0 +1,525 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../utils/trace_node_resolver.dart';
class ContactTraceSheet extends StatefulWidget {
final Contact contact;
const ContactTraceSheet({super.key, required this.contact});
@override
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
}
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_ContactTraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final localNodes = _localNodesFromContacts(
contactsProvider,
connectionProvider: connectionProvider,
);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
if (_isCompleteTrace(trace)) {
return trace;
}
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
return trace;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_ContactTraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Failed to load trace: ${snapshot.error}'),
),
),
);
}
final trace = snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.toList();
final mapPoints = concreteNodes
.map((node) => LatLng(node.latitude, node.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? 'Direct route to ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: concreteNodes
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concreteNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relays (${relayNodes.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (relayNodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this contact.',
),
),
...relayNodes.map(
(node) => ListTile(
leading: const Icon(Icons.router),
title: Text(node.name),
subtitle: Text(
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))}'
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
),
),
),
],
),
),
],
),
);
},
),
);
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[];
if (trace.sender.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.sender));
}
entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
);
}),
);
if (trace.recipient.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient));
}
return entries;
}
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
bool _isCompleteTrace(_ContactTraceResult trace) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.routeHashes.isEmpty) {
return true;
}
return trace.matchedRelayNodes.every((node) => node.node != null);
}
_ContactTraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? selfPublicKey,
}) {
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(selfPublicKey),
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(widget.contact.publicKey),
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
final routeHashes =
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
? widget.contact.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toLowerCase())
.toList()
: const <String>[];
final matchedRelayNodes = routeHashes
.map(
(hash) => TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hash,
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
)
.toList();
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
List<MeshMapNode> _localNodesFromContacts(
ContactsProvider contactsProvider, {
required ConnectionProvider connectionProvider,
}) {
final nodes = contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.toList();
final selfNode = _selfNode(connectionProvider);
if (selfNode != null) {
nodes.add(selfNode);
}
return nodes;
}
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
final publicKey = connectionProvider.deviceInfo.publicKey;
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (publicKey == null || advLat == null || advLon == null) {
return null;
}
if (advLat == 0 && advLon == 0) {
return null;
}
return MeshMapNode(
type: -1,
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: 'You',
publicKey: publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase(),
latitude: advLat / 1e6,
longitude: advLon / 1e6,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
}
class _ContactTraceResult {
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> routeHashes;
final List<ResolvedTraceNode> matchedRelayNodes;
const _ContactTraceResult({
required this.sender,
required this.recipient,
required this.routeHashes,
required this.matchedRelayNodes,
});
}
class _RouteDisplayEntry {
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
});
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
);
}
}

View File

@@ -104,6 +104,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final textColor = final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of( final backgroundColor = Theme.of(
context, context,
).colorScheme.primary.withValues(alpha: 0.12); ).colorScheme.primary.withValues(alpha: 0.12);
@@ -130,8 +131,8 @@ class _MessageBubbleState extends State<MessageBubble> {
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1), margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -141,8 +142,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'@$mentionName', '@$mentionName',
style: baseBodyStyle?.copyWith( style: baseBodyStyle?.copyWith(
color: textColor, color: textColor,
fontWeight: FontWeight.w700, fontSize: mentionFontSize,
height: 1.1, fontWeight: FontWeight.w600,
height: 1.0,
), ),
), ),
), ),
@@ -1788,6 +1790,7 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails?.rssiDbm ?? receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ?? matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm; message.lastEchoRssiDbm;
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name // Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -2071,9 +2074,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Text( child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage color: isOwnMessage
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,
@@ -2515,7 +2520,7 @@ class _MessageBubbleState extends State<MessageBubble> {
switch (recipient.deliveryStatus) { switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
statusColor = Colors.green; statusColor = Colors.green;
statusIcon = Icons.check_circle; statusIcon = Icons.done_all;
statusText = statusText =
recipient.roundTripTimeMs != null recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms' ? '${recipient.roundTripTimeMs}ms'
@@ -2523,6 +2528,15 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
)!.delivered; )!.delivered;
break; break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
statusColor = Colors.red; statusColor = Colors.red;
statusIcon = Icons.cancel; statusIcon = Icons.cancel;
@@ -2531,7 +2545,6 @@ class _MessageBubbleState extends State<MessageBubble> {
)!.failed; )!.failed;
break; break;
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
case MessageDeliveryStatus.sent:
default: default:
statusColor = Colors.orange; statusColor = Colors.orange;
statusIcon = Icons.schedule; statusIcon = Icons.schedule;
@@ -2726,6 +2739,7 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
message: message, message: message,
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
), ),
], ],
); );

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
BuildContext context, { BuildContext context, {
required Message message, required Message message,
required bool isSarMarker, required bool isSarMarker,
MessageRouteMetadata? routeMetadata,
}) { }) {
final metaColor = Theme.of( final metaColor = Theme.of(
context, context,
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
).textTheme.labelSmall?.copyWith(color: metaColor), ).textTheme.labelSmall?.copyWith(color: metaColor),
), ),
]); ]);
} else if (!isSarMarker && message.pathLen < 255) { } else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
final effectivePathLen = _effectivePathLen(message, routeMetadata);
items.addAll([ items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor), Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: metaColor), ).textTheme.labelSmall?.copyWith(color: metaColor),
@@ -124,17 +127,27 @@ Widget buildBubbleMetaFooter(
); );
} }
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
routeMetadata?.hopCount ?? message.pathLen;
Widget buildChannelHeaderPill( Widget buildChannelHeaderPill(
BuildContext context, { BuildContext context, {
required String label, required String label,
IconData icon = Icons.campaign_outlined, IconData icon = Icons.campaign_outlined,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
double iconSize = 11,
double iconSpacing = 5,
TextStyle? textStyle,
}) { }) {
final labelColor = Theme.of( final labelColor = Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82); ).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
@@ -146,19 +159,21 @@ Widget buildChannelHeaderPill(
children: [ children: [
Icon( Icon(
icon, icon,
size: 11, size: iconSize,
color: Theme.of( color: Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7), ).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
), ),
const SizedBox(width: 5), SizedBox(width: iconSpacing),
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style:
color: labelColor, textStyle ??
fontWeight: FontWeight.w600, Theme.of(context).textTheme.labelSmall?.copyWith(
), color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -176,5 +191,16 @@ Widget buildDirectHeaderCounterpart(
context, context,
label: label, label: label,
icon: Icons.alternate_email, icon: Icons.alternate_email,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
iconSize: 10,
iconSpacing: 4,
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 10,
height: 1.0,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart'; import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart'; import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
_techChip( _techChip(
context, context,
icon: Icons.alt_route, icon: Icons.alt_route,
label: hopDisplayLabel(message), label: hopDisplayLabelForMessage(message, routeMetadata),
color: Colors.indigo, color: Colors.indigo,
), ),
_techChip( _techChip(
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
} }
String hopDisplayLabelForMessage(
Message message,
MessageRouteMetadata? routeMetadata,
) {
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return 'Direct';
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
if (effectivePathLen >= 255) return 'Unknown';
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
}
Widget _techChip( Widget _techChip(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -10,9 +10,11 @@ import '../../models/ble_packet_log.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget { class MessageTraceSheet extends StatefulWidget {
final Message message; final Message message;
@@ -35,11 +37,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs( final storedPath = messagesProvider
logs: connectionProvider.bleService.packetLogs, .getMessageReceptionDetails(widget.message.id)
message: widget.message, ?.pathBytes;
); final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null final recipientPrefix = widget.message.recipientPublicKey != null
@@ -47,8 +55,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); : _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final localNodes = _localNodesFromContacts(contactsProvider); final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
nodes: localNodes, nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -72,6 +82,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
trace = _buildTraceResult( trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes), nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -107,8 +118,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final trace = snapshot.data!; final trace = snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries final concretePathNodes = routeEntries
.where((entry) => entry.node != null) .where((entry) => entry.resolved.node != null)
.map((entry) => entry.node!) .map((entry) => entry.resolved.node!)
.toList(); .toList();
final mapPoints = concretePathNodes final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude)) .map((n) => LatLng(n.latitude, n.longitude))
@@ -282,7 +293,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}', '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
), ),
), ),
), ),
@@ -326,7 +337,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<MeshMapNode> _relayNodes(_TraceResult trace) { List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final concrete = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (concrete.isEmpty) return const []; if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
@@ -339,13 +353,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) { if (pathNodes.isEmpty) {
return [ return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!), if (trace.sender.node != null)
if (trace.recipient != null && _RouteDisplayEntry.fromResolved(trace.sender),
trace.recipient!.publicKey != trace.sender?.publicKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
@@ -353,29 +371,34 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase(); final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: entry.value, resolved: entry.value,
label: entry.value?.name ?? 'Unknown', label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value != null keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value!.publicKey) ? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex, : hashHex,
matchSummary: entry.value.matchSummary,
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
...entries, ...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
final firstKey = pathNodes.first.publicKey; final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
if (trace.sender != null && trace.sender!.publicKey != firstKey) if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!), _RouteDisplayEntry.fromResolved(trace.sender),
...pathNodes.map(_RouteDisplayEntry.fromNode), ...trace.matchedPathNodes
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) .where((entry) => entry.node != null)
_RouteDisplayEntry.fromNode(trace.recipient!), .map(_RouteDisplayEntry.fromResolved),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
@@ -398,14 +421,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.toLowerCase(); .toLowerCase();
} }
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null;
final matches =
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first;
}
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) { List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation return contactsProvider.contactsWithLocation
.map((contact) { .map((contact) {
@@ -440,13 +455,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({ _TraceResult _buildTraceResult({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath, required List<int>? packetPath,
required int preferredHashSize, required int preferredHashSize,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix); final senderNode = TraceNodeResolver.resolveBest(
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: senderPrefix,
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: recipientPrefix,
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
if (packetPath != null && packetPath.isNotEmpty) { if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize( final hashSize = LogRxRouteDecoder.inferHashSize(
@@ -456,12 +486,15 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes( final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath, packetPath,
hashSize: hashSize, hashSize: hashSize,
); ).reversed.toList();
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes, pathHashes: hopHashes,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
); );
return _TraceResult( return _TraceResult(
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
@@ -474,14 +507,20 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final inferred = _inferRelaysFromHopCount( final inferred = _inferRelaysFromHopCount(
nodes: nodes, nodes: nodes,
sender: senderNode, sender: senderNode.node,
recipient: recipientNode, recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen), relayCount: math.max(0, widget.message.pathLen),
); );
final matchedPathNodes = <MeshMapNode?>[ final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode != null) senderNode, if (senderNode.node != null) senderNode,
...inferred, ...inferred.map(
if (recipientNode != null) recipientNode, (node) => ResolvedTraceNode(
node: node,
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
]; ];
return _TraceResult( return _TraceResult(
@@ -494,16 +533,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) { bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender == null || trace.recipient == null) { if (trace.sender.node == null || trace.recipient.node == null) {
return false; return false;
} }
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length && return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null); trace.matchedPathNodes.every((node) => node.node != null);
} }
final concreteCount = trace.matchedPathNodes final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>() .whereType<MeshMapNode>()
.length; .length;
return concreteCount >= expectedRelayCount + 2; return concreteCount >= expectedRelayCount + 2;
@@ -544,33 +584,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return decoded.pathBytes; return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<String> pathHashes, required List<String> pathHashes,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) { }) {
final result = <MeshMapNode?>[]; final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toLowerCase(); final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes result.add(
.where((n) => n.publicKey.startsWith(hashHex)) TraceNodeResolver.resolveBest(
.toList(); nodes: nodes,
if (candidates.isEmpty) { localPublicKeys: localPublicKeys,
result.add(null); prefixHex: hashHex,
continue; preferredPrefix: i == 0
} ? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
List<MeshMapNode> filtered = candidates; referenceA: senderLatLng,
if (i == 0 && senderPrefix != null) { referenceB: recipientLatLng,
final senderMatches = filtered ),
.where((n) => n.publicKey.startsWith(senderPrefix)) );
.toList();
if (senderMatches.isNotEmpty) filtered = senderMatches;
}
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
result.add(filtered.first);
} }
return result; return result;
} }
@@ -639,10 +676,10 @@ enum TraceMode { packetPath, hopCountInference }
class _TraceResult { class _TraceResult {
final TraceMode mode; final TraceMode mode;
final MeshMapNode? sender; final ResolvedTraceNode sender;
final MeshMapNode? recipient; final ResolvedTraceNode recipient;
final List<String> pathHashes; final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes; final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({ const _TraceResult({
required this.mode, required this.mode,
@@ -654,24 +691,30 @@ class _TraceResult {
} }
class _RouteDisplayEntry { class _RouteDisplayEntry {
final MeshMapNode? node; final ResolvedTraceNode resolved;
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.node, required this.resolved,
required this.label, required this.label,
required this.keyLabel, required this.keyLabel,
required this.matchSummary,
}); });
factory _RouteDisplayEntry.fromNode(MeshMapNode node) { MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
final node = resolved.node!;
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: node, resolved: resolved,
label: node.name, label: node.name,
keyLabel: node.publicKey.substring( keyLabel: node.publicKey.substring(
0, 0,
math.min(12, node.publicKey.length), math.min(12, node.publicKey.length),
), ),
matchSummary: resolved.matchSummary,
); );
} }
} }

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
@@ -457,6 +458,24 @@ void main() {
expect(updated.routeCanonicalText, 'AABB,CCDD'); expect(updated.routeCanonicalText, 'AABB,CCDD');
}); });
test('stores inferred fallback gps when route is set locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
const fallback = LatLng(46.1001, 14.5002);
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
inferredFallbackLocation: fallback,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
expect(updated.advertHistory, isNotEmpty);
});
test('resetContactRouteLocal clears route state', () { test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC'); final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal( provider.setContactRouteLocal(
@@ -471,6 +490,153 @@ void main() {
expect(updated.routeHasPath, isFalse); expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown'); expect(updated.routeSummary, 'Flood/Unknown');
}); });
test('retains existing route when contact refresh omits path', () {
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
name: 'Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('applies retained pending advert route when contact is resolved', () {
final pendingKey = createPublicKey(96);
final retainedRoute = ContactRouteCodec.parse('1122,3344');
provider.retainReceivedRoute(
pendingKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addPendingAdvert(pendingKey);
provider.addOrUpdateContact(
createContact(
key: pendingKey,
type: ContactType.chat,
name: 'Pending Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(pendingKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, '1122,3344');
expect(
provider.pendingAdverts.where(
(advert) => advert.publicKeyHex == updated.publicKeyHex,
),
isEmpty,
);
});
test('infers a fallback location 100m from last-hop repeater', () {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(120);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'No GPS',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
final inferred = updated.displayLocation;
final repeater = provider.findContactByKey(repeaterKey)!;
final repeaterLocation = repeater.displayLocation;
expect(inferred, isNotNull);
expect(repeaterLocation, isNotNull);
final distanceMeters = Geolocator.distanceBetween(
repeaterLocation!.latitude,
repeaterLocation.longitude,
inferred!.latitude,
inferred.longitude,
);
expect(distanceMeters, closeTo(100.0, 8.0));
});
test(
'does not infer a fallback location when the contact advertises one',
() {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(121);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'Has Advert',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (45.1234 * 1e6).toInt(),
advLon: (13.8765 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
},
);
}); });
group('ContactsProvider.updateFastGps', () { group('ContactsProvider.updateFastGps', () {
@@ -533,4 +699,39 @@ void main() {
expect(after.advLon, equals(before.advLon)); expect(after.advLon, equals(before.advLon));
}); });
}); });
group('ContactsProvider saved contact groups', () {
late ContactsProvider provider;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
});
test('adds and removes saved groups by filter', () async {
expect(provider.savedContactGroups, isEmpty);
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
expect(provider.savedContactGroups, hasLength(1));
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
expect(provider.savedContactGroups, isEmpty);
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
});
test('loads persisted saved groups during initialization', () async {
await provider.addSavedGroupForFilter('rooms', 'ops');
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
});
});
} }

View File

@@ -0,0 +1,179 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
testWidgets('updates summary and stream for incoming live traffic', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh,
now: () => now,
),
),
);
logs.addAll([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 13.5,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 3)),
direction: PacketDirection.tx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 2)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]);
refresh.value += 1;
await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget);
expect(find.textContaining('RESP'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
});
testWidgets('clear live view only resets transient screen state', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
),
);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('MULTI-HOP'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No live traffic yet'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
_log(
timestamp: now,
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
);
logs.add(
_log(
timestamp: now,
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
);
refresh.value += 1;
await tester.pump();
expect(find.text('No live traffic yet'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget);
});
}

View File

@@ -0,0 +1,143 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
void main() {
test('prefers known repeater route when available', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final routedRepeater = _contact(
name: 'Routed',
type: ContactType.repeater,
seed: 10,
lat: 46.15,
lon: 14.60,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [routedRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.tokens, [
'AA',
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
]);
});
test('falls back to location-only repeaters', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final nearRepeater = _contact(
name: 'Near',
type: ContactType.repeater,
seed: 10,
lat: 46.08,
lon: 14.55,
);
final farRepeater = _contact(
name: 'Far',
type: ContactType.repeater,
seed: 11,
lat: 46.40,
lon: 15.10,
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [nearRepeater, farRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.first.displayName, 'Near');
});
test('for known routes prefers anchor closest to the chain end', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final chainHop = _contact(
name: 'Chain Hop',
type: ContactType.repeater,
seed: 0xAA,
lat: 46.01,
lon: 14.51,
);
final nearAnchor = _contact(
name: 'Near Anchor',
type: ContactType.repeater,
seed: 10,
lat: 46.03,
lon: 14.53,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final farAnchor = _contact(
name: 'Far Anchor',
type: ContactType.repeater,
seed: 11,
lat: 46.18,
lon: 14.68,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [chainHop, nearAnchor, farAnchor],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
});
}
Contact _contact({
required String name,
required ContactType type,
required int seed,
required double lat,
required double lon,
int signedPathLen = -1,
Uint8List? outPath,
}) {
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath ?? Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).round(),
advLon: (lon * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}

View File

@@ -0,0 +1,147 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
group('LiveTrafficSummary', () {
test('uses only the rolling 60-second window', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 61)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.tx,
rawData: [0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.rx,
rawData: [0x01, 0x02],
responseCode: 0x01,
),
], now: now);
expect(snapshot.totalCount, 1);
expect(snapshot.rxCount, 1);
expect(snapshot.txCount, 0);
expect(snapshot.packetsPerMinute, 1);
});
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 12.0,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 15)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
responseCode: 0x88,
snrDb: 6.0,
rssiDbm: -90,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
], now: now);
expect(snapshot.latestRssiDbm, -90);
expect(snapshot.latestSnrDb, 6.0);
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
expect(snapshot.multiHopCount, 2);
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
});
test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8));
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
], now: now, clearedAt: clearAt);
expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1));
});
});
}

View File

@@ -0,0 +1,101 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/services/message_storage_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'retains path bytes for stored unread message when reception sidecar is missing',
() async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-1',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Unread message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
pathBytes: const [0xAA, 0xBB, 0xCC],
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
expect(restoredDetails.keys, contains(message.id));
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
},
);
test('retains embedded reception details when sidecar is missing', () async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-2',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
channelIdx: 2,
pathLen: 3,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Room update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
rssiDbm: -91,
snrDb: 7.25,
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restored = restoredDetails[message.id];
expect(restored, isNotNull);
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
expect(restored.rssiDbm, -91);
expect(restored.snrDb, 7.25);
expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400);
expect(
restored.packetLoggedAt,
DateTime.fromMillisecondsSinceEpoch(1700000100400),
);
});
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_history.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart';
@@ -161,15 +162,48 @@ void main() {
expect(selection.mode, PathSelectionMode.flood); expect(selection.mode, PathSelectionMode.flood);
}); });
test('received public byte path is added to history', () async { test(
final service = PathHistoryService(); 'received public byte path is reversed before adding to history',
await service.initialize(); () async {
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3); final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123'); final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1)); expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]); expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 3); expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 1); expect(history.directPaths.single.hopCount, 2);
}); expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'learned paths stay marked as observed after being seen on-air',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 3,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordLearnedPath(contact);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
} }

View File

@@ -116,6 +116,21 @@ void main() {
expect(resolved.matchCount, 2); expect(resolved.matchCount, 2);
}); });
}); });
group('LogRxRouteDecoder.reverseHopBytes', () {
test('reverses path by hop size', () {
final reversed = LogRxRouteDecoder.reverseHopBytes([
0xc2,
0xba,
0x5f,
0xde,
0xaa,
0xbb,
], hashSize: 2);
expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]);
});
});
} }
Contact _contact({required String name, required List<int> keyPrefix}) { Contact _contact({required String name, required List<int> keyPrefix}) {

View File

@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:meshcore_sar_app/utils/trace_node_resolver.dart';
void main() {
test(
'prefers closest local repeater over online fallback for shared prefix',
() {
final localNear = _node(
name: 'Near Local',
publicKey: 'aa1100',
latitude: 46.05,
longitude: 14.50,
);
final localFar = _node(
name: 'Far Local',
publicKey: 'aa2200',
latitude: 46.40,
longitude: 14.90,
);
final online = _node(
name: 'Online',
publicKey: 'aa3300',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [localNear, localFar, online],
localPublicKeys: {localNear.publicKey, localFar.publicKey},
prefixHex: 'aa',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Near Local');
expect(resolved.usedOnlineFallback, isFalse);
expect(resolved.matchCount, 2);
expect(resolved.matchSummary, '2 local matches');
},
);
test('falls back to online node only when local match is missing', () {
final online = _node(
name: 'Online Only',
publicKey: 'bb1100',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [online],
localPublicKeys: const {},
prefixHex: 'bb',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Online Only');
expect(resolved.usedOnlineFallback, isTrue);
expect(resolved.matchCount, 1);
});
}
MeshMapNode _node({
required String name,
required String publicKey,
required double latitude,
required double longitude,
}) {
return MeshMapNode(
type: 1,
name: name,
publicKey: publicKey,
latitude: latitude,
longitude: longitude,
updatedAtMs: 1,
);
}

View File

@@ -0,0 +1,85 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46562000,
advLon: 14950000,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpTile(WidgetTester tester, Contact contact) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ContactTile(contact: contact)),
),
),
);
}
testWidgets('shows trace action for non-channel contacts', (tester) async {
await pumpTile(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
);
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsOneWidget);
});
testWidgets('does not show trace action for channels', (tester) async {
await pumpTile(
tester,
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
});
}