fix: init tabs advert quick add

ref:
This commit is contained in:
Janez T
2026-02-28 11:23:11 +01:00
parent baf49d27d8
commit 6bd7517180
32 changed files with 850 additions and 11558 deletions

View File

@@ -20,7 +20,8 @@ class AppProvider with ChangeNotifier {
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final TileCacheService tileCacheService;
final LocationTrackingService locationTrackingService = LocationTrackingService();
final LocationTrackingService locationTrackingService =
LocationTrackingService();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
@@ -61,7 +62,9 @@ class AppProvider with ChangeNotifier {
// Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100));
debugPrint('🔄 [AppProvider] Early sync: syncing drawings from messages...');
debugPrint(
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
}
@@ -129,7 +132,9 @@ class AppProvider with ChangeNotifier {
// Setup callbacks
locationTrackingService.onPositionUpdate = (position) {
debugPrint('📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}');
debugPrint(
'📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}',
);
};
locationTrackingService.onBroadcastSent = (position) {
@@ -141,7 +146,9 @@ class AppProvider with ChangeNotifier {
};
locationTrackingService.onTrackingStateChanged = (isTracking) {
debugPrint('🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}');
debugPrint(
'🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}',
);
};
debugPrint('✅ [AppProvider] Location tracking service initialized');
@@ -187,87 +194,103 @@ class AppProvider with ChangeNotifier {
};
// When channel info is received
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
try {
debugPrint('🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"');
// Check if this is a channel deletion (empty name)
if (channelName.isEmpty && channelIdx != 0) {
debugPrint(' 🗑️ Channel $channelIdx deleted - removing from providers');
// Remove from ChannelsProvider
channelsProvider.removeChannel(channelIdx);
debugPrint(' ✅ Removed from ChannelsProvider');
// Remove from ContactsProvider using pseudo public key
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final publicKeyHex = publicKeyBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
contactsProvider.removeContact(publicKeyHex);
debugPrint(' ✅ Removed from ContactsProvider');
return;
}
// Add/update in ChannelsProvider
channelsProvider.addOrUpdateChannel(
index: channelIdx,
name: channelName,
secret: secret,
flags: flags,
);
debugPrint(' ✅ Added to ChannelsProvider');
// Also add as Contact to ContactsProvider (for UI display)
// Skip if it's public channel (already exists)
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})');
if (channelName.isNotEmpty && channelIdx != 0) {
debugPrint(' ✅ Adding channel $channelIdx to ContactsProvider as Contact');
// Create a pseudo public key for the channel based on its index
// Use channel index as a unique identifier (pad to 32 bytes)
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
contactsProvider.addOrUpdateContact(
Contact(
publicKey: publicKeyBytes,
type: ContactType.channel,
flags: flags ?? 0,
outPathLen: -1, // Flood mode for channels
outPath: Uint8List(0), // Empty path for channels
advName: channelName,
lastAdvert: now,
advLat: 0, // Channels don't have location
advLon: 0,
lastMod: now,
isNew: false, // Don't mark channels as new
),
);
debugPrint(' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}');
} else {
debugPrint(' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})');
}
} catch (e, stackTrace) {
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
debugPrint(' Stack trace: $stackTrace');
}
};
connectionProvider.onChannelInfoReceived =
(int channelIdx, String channelName, Uint8List secret, int? flags) {
try {
debugPrint(
'🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"',
);
// Check if this is a channel deletion (empty name)
if (channelName.isEmpty && channelIdx != 0) {
debugPrint(
' 🗑️ Channel $channelIdx deleted - removing from providers',
);
// Remove from ChannelsProvider
channelsProvider.removeChannel(channelIdx);
debugPrint(' ✅ Removed from ChannelsProvider');
// Remove from ContactsProvider using pseudo public key
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final publicKeyHex = publicKeyBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
contactsProvider.removeContact(publicKeyHex);
debugPrint(' ✅ Removed from ContactsProvider');
return;
}
// Add/update in ChannelsProvider
channelsProvider.addOrUpdateChannel(
index: channelIdx,
name: channelName,
secret: secret,
flags: flags,
);
debugPrint(' ✅ Added to ChannelsProvider');
// Also add as Contact to ContactsProvider (for UI display)
// Skip if it's public channel (already exists)
debugPrint(
'📻 [AppProvider] Channel $channelIdx: "$channelName" (isEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
);
if (channelName.isNotEmpty && channelIdx != 0) {
debugPrint(
' ✅ Adding channel $channelIdx to ContactsProvider as Contact',
);
// Create a pseudo public key for the channel based on its index
// Use channel index as a unique identifier (pad to 32 bytes)
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
contactsProvider.addOrUpdateContact(
Contact(
publicKey: publicKeyBytes,
type: ContactType.channel,
flags: flags ?? 0,
outPathLen: -1, // Flood mode for channels
outPath: Uint8List(0), // Empty path for channels
advName: channelName,
lastAdvert: now,
advLat: 0, // Channels don't have location
advLon: 0,
lastMod: now,
isNew: false, // Don't mark channels as new
),
);
debugPrint(
' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}',
);
} else {
debugPrint(
' ⏭️ Skipping channel $channelIdx (empty: ${channelName.isEmpty}, isPublic: ${channelIdx == 0})',
);
}
} catch (e, stackTrace) {
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
debugPrint(' Stack trace: $stackTrace');
}
};
// When a message is received
connectionProvider.onMessageReceived = (message) {
// Enrich message with sender name from contacts first
Message enrichedMessage = message;
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
final contact = contactsProvider
.findContactByKey(message.senderPublicKeyPrefix!);
final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!,
);
if (contact != null) {
enrichedMessage = message.copyWith(senderName: contact.advName);
}
@@ -281,10 +304,13 @@ class AppProvider with ChangeNotifier {
final drawing = DrawingMessageParser.parseDrawingMessage(
enrichedMessage.text,
senderName: senderName,
messageId: enrichedMessage.id, // Pass message ID for navigation linking
messageId:
enrichedMessage.id, // Pass message ID for navigation linking
);
if (drawing != null) {
debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}');
debugPrint(
'🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}',
);
debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}');
drawingProvider.addReceivedDrawing(drawing);
@@ -318,7 +344,8 @@ class AppProvider with ChangeNotifier {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty && contact.publicKeyHex.length >= 12
return contact.publicKeyHex.isNotEmpty &&
contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (e) {
@@ -335,7 +362,9 @@ class AppProvider with ChangeNotifier {
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
debugPrint('📊 [AppProvider] Telemetry response (0x8B) received - updating contact');
debugPrint(
'📊 [AppProvider] Telemetry response (0x8B) received - updating contact',
);
contactsProvider.updateTelemetry(publicKey, lppData);
};
@@ -343,7 +372,9 @@ class AppProvider with ChangeNotifier {
// Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
debugPrint('📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry');
debugPrint(
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
);
// Binary response tag 0 = telemetry data (Cayenne LPP format)
// Other tags may be used for different data types in the future
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
@@ -351,7 +382,9 @@ class AppProvider with ChangeNotifier {
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint('🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
debugPrint(
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
// Trigger a single contact fetch to get the updated path information
// This is much more efficient than fetching all contacts
// This happens asynchronously to avoid blocking the event handler
@@ -365,11 +398,15 @@ class AppProvider with ChangeNotifier {
// When an advertisement is received (PUSH_CODE_ADVERT 0x80)
// This may be sent by the radio for existing contacts instead of PUSH_CODE_NEW_ADVERT (0x8A)
connectionProvider.onAdvertReceived = (publicKey) {
debugPrint('📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...');
debugPrint(
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
// Check if this is an existing contact that might have updated location
final contact = contactsProvider.findContactByKey(publicKey);
if (contact != null) {
debugPrint(' Existing contact "${contact.advName}" - fetching updated contact info (optimized)');
debugPrint(
' Existing contact "${contact.advName}" - fetching updated contact info (optimized)',
);
// Trigger a single contact fetch to get the updated contact information
// This is much more efficient than fetching all contacts
Future.delayed(const Duration(milliseconds: 100), () {
@@ -378,44 +415,88 @@ class AppProvider with ChangeNotifier {
}
});
} else {
debugPrint(' New contact - waiting for PUSH_CODE_NEW_ADVERT (0x8A) with full details');
contactsProvider.addPendingAdvert(
publicKey,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
debugPrint(
' Unknown contact - added to pending adverts list and waiting for details',
);
}
};
// When firmware deletes a contact due to contacts table overflow (PUSH_CODE_CONTACT_DELETED 0x8F)
connectionProvider.onContactDeleted = (publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final contact = contactsProvider.findContactByKey(publicKey);
final name = contact?.advName ?? keyHex.substring(0, 12);
debugPrint('⚠️ [AppProvider] Contact deleted by firmware: $name');
contactsProvider.removeContact(keyHex);
messagesProvider.logSystemMessage(
text: 'Contact "$name" was removed — device contacts table is full',
level: 'warning',
);
};
// When firmware reports contacts storage is full (PUSH_CODE_CONTACTS_FULL 0x90)
connectionProvider.onContactsFull = () {
debugPrint('⚠️ [AppProvider] Contacts storage full');
messagesProvider.logSystemMessage(
text:
'Device contacts storage is full. New contacts will overwrite old ones.',
level: 'warning',
);
};
// When a message is sent (RESP_CODE_SENT received)
connectionProvider.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
debugPrint('📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag');
messagesProvider.markMessageSent(messageId, expectedAckTag, suggestedTimeoutMs);
connectionProvider
.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
debugPrint(
'📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag',
);
messagesProvider.markMessageSent(
messageId,
expectedAckTag,
suggestedTimeoutMs,
);
};
// When a message is delivered (PUSH_CODE_SEND_CONFIRMED received)
connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) {
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
debugPrint(
'✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
);
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
connectionProvider.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
debugPrint('🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount');
connectionProvider
.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
debugPrint(
'🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount',
);
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback = ({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
return await connectionProvider.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
messagesProvider.sendMessageCallback =
({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
return await connectionProvider.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
}
/// Initialize the app (load contacts, sync time, etc.)
@@ -446,19 +527,25 @@ class AppProvider with ChangeNotifier {
// In simple mode: only sync first 5 channels for faster startup
// In normal mode: sync all channels (up to device max)
final channelsToSync = _isSimpleMode ? 5 : null;
debugPrint('📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...');
debugPrint(
'📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...',
);
await connectionProvider.syncChannels(maxChannels: channelsToSync);
debugPrint('✅ [AppProvider] Channel sync complete');
// Configure the default public channel (channel 0)
// This must be done before sending any channel messages
// Note: Some firmware versions may have this pre-configured
debugPrint('📻 [AppProvider] Configuring default public channel (channel 0)...');
debugPrint(
'📻 [AppProvider] Configuring default public channel (channel 0)...',
);
try {
await connectionProvider.configureDefaultPublicChannel();
debugPrint('✅ [AppProvider] Public channel configured successfully');
} catch (e) {
debugPrint('⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e');
debugPrint(
'⚠️ [AppProvider] Public channel configuration failed (may already be configured): $e',
);
// Continue anyway - channel might already be configured in firmware
}
@@ -467,19 +554,27 @@ class AppProvider with ChangeNotifier {
// FALLBACK: Sync messages once after connection to catch any missed push notifications
// This handles the case where messages arrived while the app was disconnected
debugPrint('🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)');
debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
);
final initialMessageCount = await connectionProvider.syncAllMessages();
debugPrint('📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)');
debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
);
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
// Start location tracking AFTER all initialization is complete
debugPrint('📍 [AppProvider] Starting location tracking after successful initialization');
debugPrint(
'📍 [AppProvider] Starting location tracking after successful initialization',
);
await _startLocationTracking();
// Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage
debugPrint('🎨 [AppProvider] Syncing drawing messages with DrawingProvider...');
debugPrint(
'🎨 [AppProvider] Syncing drawing messages with DrawingProvider...',
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
notifyListeners();
@@ -503,7 +598,9 @@ class AppProvider with ChangeNotifier {
return;
}
debugPrint('📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...');
debugPrint(
'📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...',
);
final prefs = await SharedPreferences.getInstance();
@@ -513,7 +610,9 @@ class AppProvider with ChangeNotifier {
final roomKey = 'room_password_${room.publicKeyHex}';
final savedPassword = prefs.getString(roomKey) ?? 'hello';
debugPrint('🔑 [AppProvider] Auto-logging into room: ${room.advName}');
debugPrint(
'🔑 [AppProvider] Auto-logging into room: ${room.advName}',
);
// Set up one-time callbacks for this room login
await _loginToRoomWithCallback(room, savedPassword);
@@ -521,7 +620,9 @@ class AppProvider with ChangeNotifier {
// Small delay between logins to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 300));
} catch (e) {
debugPrint('❌ [AppProvider] Failed to auto-login to ${room.advName}: $e');
debugPrint(
'❌ [AppProvider] Failed to auto-login to ${room.advName}: $e',
);
}
}
} catch (e) {
@@ -539,13 +640,16 @@ class AppProvider with ChangeNotifier {
final originalOnFail = connectionProvider.onLoginFail;
// Set up temporary callbacks
connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
connectionProvider
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
debugPrint('📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING');
debugPrint(
'📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING',
);
completer.complete(true);
};
@@ -555,7 +659,9 @@ class AppProvider with ChangeNotifier {
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)');
debugPrint(
'❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)',
);
completer.complete(false);
};
@@ -581,7 +687,9 @@ class AppProvider with ChangeNotifier {
// Restore callbacks on error
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('❌ [AppProvider] Error during auto-login to ${room.advName}: $e');
debugPrint(
'❌ [AppProvider] Error during auto-login to ${room.advName}: $e',
);
}
}
@@ -595,11 +703,11 @@ class AppProvider with ChangeNotifier {
try {
// Sync contacts
await connectionProvider.getContacts();
// Sync channels (respect simple mode settings)
final channelsToSync = _isSimpleMode ? 5 : null;
await connectionProvider.syncChannels(maxChannels: channelsToSync);
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
notifyListeners();
} catch (e) {
@@ -614,9 +722,13 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) return 0;
try {
debugPrint('🔄 [AppProvider] Manual message sync requested (user initiated)');
debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)',
);
final messageCount = await connectionProvider.syncAllMessages();
debugPrint('✅ [AppProvider] Manual sync completed: $messageCount messages');
debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages',
);
notifyListeners();
return messageCount;
} catch (e) {
@@ -634,7 +746,9 @@ class AppProvider with ChangeNotifier {
// Location tracking will be started AFTER initialization completes
if (!isConnected && wasTracking) {
// Connection lost - stop location tracking
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
debugPrint(
'🔴 [AppProvider] BLE disconnected - stopping location tracking',
);
_stopLocationTracking();
}
}

View File

@@ -157,6 +157,8 @@ class ConnectionProvider with ChangeNotifier {
Function(int channelIdx, String channelName, Uint8List secret, int? flags)? onChannelInfoReceived;
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
onBinaryResponse;
Function(Uint8List publicKey)? onContactDeleted;
VoidCallback? onContactsFull;
Function(Uint8List publicKey)? onAdvertReceived;
Function(Uint8List publicKey)? onPathUpdated;
Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag)?
@@ -319,6 +321,16 @@ class ConnectionProvider with ChangeNotifier {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_bleService.onContactDeleted = (publicKey) {
debugPrint('⚠️ [Provider] Contact deleted by firmware (contacts full overwrite)');
onContactDeleted?.call(publicKey);
};
_bleService.onContactsFull = () {
debugPrint('⚠️ [Provider] Contacts storage is full');
onContactsFull?.call();
};
_bleService.onMessageReceived = (message) {
// Parse SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);

View File

@@ -4,9 +4,32 @@ import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../utils/key_comparison.dart';
class PendingAdvert {
final Uint8List publicKey;
final DateTime receivedAt;
const PendingAdvert({required this.publicKey, required this.receivedAt});
String get publicKeyHex =>
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
String get shortDisplayKey {
final prefix = publicKey.length >= 6 ? publicKey.sublist(0, 6) : publicKey;
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
}
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) {
return PendingAdvert(
publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt,
);
}
}
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
@@ -156,6 +179,9 @@ class ContactsProvider with ChangeNotifier {
}
List<Contact> get contacts => _contacts.values.toList();
List<PendingAdvert> get pendingAdverts =>
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
@@ -195,11 +221,12 @@ class ContactsProvider with ChangeNotifier {
/// Add or update a contact
/// Excludes contacts that match the device's own public key
void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) {
debugPrint('📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)');
debugPrint(
'📝 [ContactsProvider] addOrUpdateContact called: ${contact.advName} (type: ${contact.type.displayName}, key: ${contact.publicKeyHex.substring(0, 8)}...)',
);
// Don't add contacts that match our device's public key
if (devicePublicKey != null &&
contact.publicKey.matches(devicePublicKey)) {
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
debugPrint(
' [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}',
);
@@ -208,7 +235,9 @@ class ContactsProvider with ChangeNotifier {
// Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
debugPrint(' isNew: $isNewContact, total contacts before: ${_contacts.length}');
debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
);
Contact updatedContact;
if (isNewContact) {
@@ -246,7 +275,10 @@ class ContactsProvider with ChangeNotifier {
}
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
_pendingAdverts.remove(contact.publicKeyHex);
debugPrint(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
);
_persistContacts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
@@ -267,6 +299,7 @@ class ContactsProvider with ChangeNotifier {
continue;
}
_contacts[contact.publicKeyHex] = contact;
_pendingAdverts.remove(contact.publicKeyHex);
}
if (excluded > 0) {
debugPrint(
@@ -303,8 +336,8 @@ class ContactsProvider with ChangeNotifier {
// Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds
final currentTimestamp =
(DateTime.now().millisecondsSinceEpoch / 1000).round();
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
.round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
@@ -351,6 +384,34 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex];
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex);
return;
}
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
if (existing != null) {
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
} else {
_pendingAdverts[keyHex] = PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
);
}
notifyListeners();
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
@@ -404,6 +465,7 @@ class ContactsProvider with ChangeNotifier {
/// Clear all contacts
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_persistContacts();
notifyListeners();
}
@@ -425,6 +487,7 @@ class ContactsProvider with ChangeNotifier {
// Then remove from local storage
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_persistContacts();
notifyListeners();
}

View File

@@ -20,6 +20,7 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
@override
void initState() {
@@ -59,6 +60,25 @@ class _ContactsTabState extends State<ContactsTab> {
await _getCurrentLocation();
}
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(
double lat1,
@@ -90,6 +110,15 @@ class _ContactsTabState extends State<ContactsTab> {
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
/// Show the add channel dialog
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
@@ -140,11 +169,14 @@ class _ContactsTabState extends State<ContactsTab> {
final repeaters = contactsProvider.repeaters;
final rooms = contactsProvider.rooms;
final channels = contactsProvider.channels;
final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts (excluding channels)
final hasDisplayableContacts = chatContacts.isNotEmpty ||
final hasDisplayableContacts =
chatContacts.isNotEmpty ||
repeaters.isNotEmpty ||
rooms.isNotEmpty;
rooms.isNotEmpty ||
pendingAdverts.isNotEmpty;
if (!hasDisplayableContacts) {
return Center(
@@ -177,6 +209,27 @@ class _ContactsTabState extends State<ContactsTab> {
child: ListView(
padding: const EdgeInsets.all(8),
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)
if (chatContacts.isNotEmpty) ...[
_SectionHeader(
@@ -280,6 +333,46 @@ class _ContactsTabState extends State<ContactsTab> {
}
}
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
final bool isResolving;
final VoidCallback onResolve;
const _PendingAdvertTile({
required this.advert,
required this.subtitle,
required this.isResolving,
required this.onResolve,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
title: Text(
advert.shortDisplayKey,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(subtitle),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Quick add',
onPressed: onResolve,
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final int count;

View File

@@ -53,6 +53,8 @@ class _HomeScreenState extends State<HomeScreen>
@override
void initState() {
super.initState();
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadMapEnabledAndInitTabs();
_loadRxTxPreference();
@@ -67,12 +69,9 @@ class _HomeScreenState extends State<HomeScreen>
Future<void> _loadMapEnabledAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
if (mapEnabled != _isMapEnabled) {
_isMapEnabled = mapEnabled;
}
_initTabController();
if (mounted) {
setState(() {});
if (!mounted) return;
if (_isMapEnabled != mapEnabled) {
_updateTabController(mapEnabled);
}
}
@@ -286,7 +285,8 @@ class _HomeScreenState extends State<HomeScreen>
}
// Determine if we should hide the UI (only in fullscreen on map tab)
final shouldHideUI = _isMapEnabled && _isMapFullscreen && _currentIndex == 2;
final shouldHideUI =
_isMapEnabled && _isMapFullscreen && _currentIndex == 2;
return Scaffold(
appBar: shouldHideUI
@@ -296,7 +296,9 @@ class _HomeScreenState extends State<HomeScreen>
actions: [
Consumer<ConnectionProvider>(
builder: (context, provider, child) {
final isConnected = provider.deviceInfo.isConnected || provider.isSseClientConnected;
final isConnected =
provider.deviceInfo.isConnected ||
provider.isSseClientConnected;
if (isConnected) {
return IconButton(
onPressed: () async {
@@ -540,11 +542,14 @@ class _HomeScreenState extends State<HomeScreen>
color: isSseConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
: Colors.grey),
? BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
)
: Colors.grey),
size: 13,
),
if (isBleConnected && deviceInfo.signalRssi != null) ...[
if (isBleConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 3),
Text(
'${deviceInfo.signalRssi}',
@@ -569,7 +574,9 @@ class _HomeScreenState extends State<HomeScreen>
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(deviceInfo.batteryPercent!),
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),

View File

@@ -98,6 +98,8 @@ class BleResponseHandler {
OnChannelInfoCallback? onChannelInfoReceived;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
VoidCallback? onRxActivity;
void Function(Uint8List publicKey)? onContactDeleted;
VoidCallback? onContactsFull;
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
Uint8List? _lastContactPublicKey;
@@ -183,6 +185,14 @@ class BleResponseHandler {
debugPrint(' → Handling ChannelMessage');
_handleChannelMessage(reader);
break;
case MeshCoreConstants.respContactMsgRecvV3:
debugPrint(' → Handling ContactMessage V3');
_handleContactMessageV3(reader);
break;
case MeshCoreConstants.respChannelMsgRecvV3:
debugPrint(' → Handling ChannelMessage V3');
_handleChannelMessageV3(reader);
break;
case MeshCoreConstants.pushTelemetryResponse:
debugPrint(' → Handling TelemetryResponse');
_handleTelemetryResponse(reader);
@@ -251,6 +261,20 @@ class BleResponseHandler {
debugPrint(' → Response: No More Messages');
onNoMoreMessages?.call();
break;
case MeshCoreConstants.pushPathDiscoveryResponse:
debugPrint(' → Path discovery response (not yet handled)');
break;
case MeshCoreConstants.pushControlData:
debugPrint(' → Control data push (not yet handled)');
break;
case MeshCoreConstants.pushContactDeleted:
debugPrint(' → Handling ContactDeleted push');
_handleContactDeleted(reader);
break;
case MeshCoreConstants.pushContactsFull:
debugPrint(' → Contacts storage full');
onContactsFull?.call();
break;
case MeshCoreConstants.respOk:
debugPrint(' → Response: OK');
// Complete any pending ACK command
@@ -349,6 +373,43 @@ class BleResponseHandler {
}
}
/// Handle ContactMessage V3 response (firmware ver >= 3, has SNR header)
void _handleContactMessageV3(BufferReader reader) {
try {
final message = FrameParser.parseContactMessageV3(reader);
debugPrint(' ✅ [ContactMessage V3] Parsed successfully');
onMessageReceived?.call(message);
} catch (e) {
debugPrint(' ❌ [ContactMessage V3] Parsing error: $e');
onError?.call('Contact message V3 parsing error: $e');
}
}
/// Handle ChannelMessage V3 response (firmware ver >= 3, has SNR header)
void _handleChannelMessageV3(BufferReader reader) {
try {
final message = FrameParser.parseChannelMessageV3(reader);
debugPrint(' ✅ [ChannelMessage V3] Parsed successfully');
onMessageReceived?.call(message);
} catch (e) {
debugPrint(' ❌ [ChannelMessage V3] Parsing error: $e');
onError?.call('Channel message V3 parsing error: $e');
}
}
/// Handle ContactDeleted push (0x8F) — contact overwritten due to contacts full
void _handleContactDeleted(BufferReader reader) {
try {
if (reader.remainingBytesCount >= 32) {
final publicKey = reader.readBytes(32);
debugPrint(' ✅ [ContactDeleted] Contact removed by firmware');
onContactDeleted?.call(Uint8List.fromList(publicKey));
}
} catch (e) {
debugPrint(' ❌ [ContactDeleted] Parsing error: $e');
}
}
/// Handle TelemetryResponse push
void _handleTelemetryResponse(BufferReader reader) {
try {

View File

@@ -91,6 +91,8 @@ class MeshCoreBleService {
OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
OnChannelInfoCallback? onChannelInfoReceived;
void Function(Uint8List publicKey)? onContactDeleted;
VoidCallback? onContactsFull;
// Activity callbacks (for blinking indicators)
VoidCallback? onRxActivity;
@@ -213,6 +215,12 @@ class MeshCoreBleService {
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_responseHandler.onContactDeleted = (publicKey) {
onContactDeleted?.call(publicKey);
};
_responseHandler.onContactsFull = () {
onContactsFull?.call();
};
_responseHandler.onRxActivity = () {
onRxActivity?.call();
};

View File

@@ -1,7 +1,7 @@
/// MeshCore BLE and Protocol Constants
class MeshCoreConstants {
// Supported protocol version
static const int supportedCompanionProtocolVersion = 1;
// Supported protocol version (firmware uses this to decide V1 vs V3 message frames)
static const int supportedCompanionProtocolVersion = 3;
// BLE Service and Characteristic UUIDs
static const String bleServiceUuid =
@@ -39,6 +39,8 @@ class MeshCoreConstants {
static const int cmdSendRawData = 25;
static const int cmdSendLogin = 26;
static const int cmdSendStatusReq = 27;
static const int cmdHasConnection = 28;
static const int cmdLogout = 29;
static const int cmdGetContactByKey = 30;
static const int cmdGetChannel = 31;
static const int cmdSetChannel = 32;
@@ -46,9 +48,23 @@ class MeshCoreConstants {
static const int cmdSignData = 34;
static const int cmdSignFinish = 35;
static const int cmdSendTracePath = 36;
static const int cmdSetDevicePin = 37;
static const int cmdSetOtherParams = 38;
static const int cmdSendTelemetryReq = 39;
static const int cmdGetCustomVars = 40;
static const int cmdSetCustomVar = 41;
static const int cmdGetAdvertPath = 42;
static const int cmdGetTuningParams = 43;
static const int cmdSendBinaryReq = 50;
static const int cmdFactoryReset = 51;
static const int cmdSendPathDiscoveryReq = 52;
static const int cmdSetFloodScope = 54; // v8+
static const int cmdSendControlData = 55; // v8+
static const int cmdGetStats = 56; // v8+
static const int cmdSendAnonReq = 57;
static const int cmdSetAutoaddConfig = 58;
static const int cmdGetAutoaddConfig = 59;
static const int cmdGetAllowedRepeatFreq = 60;
// Response Codes (Device -> App)
static const int respOk = 0;
@@ -58,8 +74,8 @@ class MeshCoreConstants {
static const int respEndOfContacts = 4;
static const int respSelfInfo = 5;
static const int respSent = 6;
static const int respContactMsgRecv = 7;
static const int respChannelMsgRecv = 8;
static const int respContactMsgRecv = 7; // firmware ver < 3
static const int respChannelMsgRecv = 8; // firmware ver < 3
static const int respCurrTime = 9;
static const int respNoMoreMessages = 10;
static const int respExportContact = 11;
@@ -67,12 +83,17 @@ class MeshCoreConstants {
static const int respDeviceInfo = 13;
static const int respPrivateKey = 14;
static const int respDisabled = 15;
static const int respContactMsgRecvV3 = 16; // firmware ver >= 3 (adds SNR header)
static const int respChannelMsgRecvV3 = 17; // firmware ver >= 3 (adds SNR header)
static const int respChannelInfo = 18;
static const int respSignStart = 19;
static const int respSignature = 20;
static const int respCustomVars = 21;
static const int respAdvertPath = 22;
static const int respTuningParams = 21; // Same as respCustomVars per protocol
static const int respTuningParams = 23;
static const int respStats = 24; // v8+
static const int respAutoaddConfig = 25;
static const int respAllowedRepeatFreq = 26;
// Push Codes (Device -> App, unsolicited)
static const int pushAdvert = 0x80;
@@ -88,6 +109,15 @@ class MeshCoreConstants {
static const int pushNewAdvert = 0x8A;
static const int pushTelemetryResponse = 0x8B;
static const int pushBinaryResponse = 0x8C;
static const int pushPathDiscoveryResponse = 0x8D;
static const int pushControlData = 0x8E; // v8+
static const int pushContactDeleted = 0x8F; // contact overwritten when contacts full
static const int pushContactsFull = 0x90; // contacts storage is full
// Stats sub-types for cmdGetStats
static const int statsTypeCore = 0;
static const int statsTypeRadio = 1;
static const int statsTypePackets = 2;
// Error Codes
static const int errUnsupportedCmd = 1;

View File

@@ -59,6 +59,10 @@ class MeshCoreOpcodeNames {
return 'SEND_LOGIN';
case MeshCoreConstants.cmdSendStatusReq:
return 'SEND_STATUS_REQ';
case MeshCoreConstants.cmdHasConnection:
return 'HAS_CONNECTION';
case MeshCoreConstants.cmdLogout:
return 'LOGOUT';
case MeshCoreConstants.cmdGetContactByKey:
return 'GET_CONTACT_BY_KEY';
case MeshCoreConstants.cmdGetChannel:
@@ -73,12 +77,40 @@ class MeshCoreOpcodeNames {
return 'SIGN_FINISH';
case MeshCoreConstants.cmdSendTracePath:
return 'SEND_TRACE_PATH';
case MeshCoreConstants.cmdSetDevicePin:
return 'SET_DEVICE_PIN';
case MeshCoreConstants.cmdSetOtherParams:
return 'SET_OTHER_PARAMS';
case MeshCoreConstants.cmdSendTelemetryReq:
return 'SEND_TELEMETRY_REQ';
case MeshCoreConstants.cmdGetCustomVars:
return 'GET_CUSTOM_VARS';
case MeshCoreConstants.cmdSetCustomVar:
return 'SET_CUSTOM_VAR';
case MeshCoreConstants.cmdGetAdvertPath:
return 'GET_ADVERT_PATH';
case MeshCoreConstants.cmdGetTuningParams:
return 'GET_TUNING_PARAMS';
case MeshCoreConstants.cmdSendBinaryReq:
return 'SEND_BINARY_REQ';
case MeshCoreConstants.cmdFactoryReset:
return 'FACTORY_RESET';
case MeshCoreConstants.cmdSendPathDiscoveryReq:
return 'SEND_PATH_DISCOVERY_REQ';
case MeshCoreConstants.cmdSetFloodScope:
return 'SET_FLOOD_SCOPE';
case MeshCoreConstants.cmdSendControlData:
return 'SEND_CONTROL_DATA';
case MeshCoreConstants.cmdGetStats:
return 'GET_STATS';
case MeshCoreConstants.cmdSendAnonReq:
return 'SEND_ANON_REQ';
case MeshCoreConstants.cmdSetAutoaddConfig:
return 'SET_AUTOADD_CONFIG';
case MeshCoreConstants.cmdGetAutoaddConfig:
return 'GET_AUTOADD_CONFIG';
case MeshCoreConstants.cmdGetAllowedRepeatFreq:
return 'GET_ALLOWED_REPEAT_FREQ';
default:
return 'CMD_UNKNOWN';
}
@@ -119,12 +151,28 @@ class MeshCoreOpcodeNames {
return 'PRIVATE_KEY';
case MeshCoreConstants.respDisabled:
return 'DISABLED';
case MeshCoreConstants.respContactMsgRecvV3:
return 'CONTACT_MSG_RECV_V3';
case MeshCoreConstants.respChannelMsgRecvV3:
return 'CHANNEL_MSG_RECV_V3';
case MeshCoreConstants.respChannelInfo:
return 'CHANNEL_INFO';
case MeshCoreConstants.respSignStart:
return 'SIGN_START';
case MeshCoreConstants.respSignature:
return 'SIGNATURE';
case MeshCoreConstants.respCustomVars:
return 'CUSTOM_VARS';
case MeshCoreConstants.respAdvertPath:
return 'ADVERT_PATH';
case MeshCoreConstants.respTuningParams:
return 'TUNING_PARAMS';
case MeshCoreConstants.respStats:
return 'STATS';
case MeshCoreConstants.respAutoaddConfig:
return 'AUTOADD_CONFIG';
case MeshCoreConstants.respAllowedRepeatFreq:
return 'ALLOWED_REPEAT_FREQ';
default:
return 'RESP_UNKNOWN';
}
@@ -159,6 +207,14 @@ class MeshCoreOpcodeNames {
return 'TELEMETRY_RESPONSE';
case MeshCoreConstants.pushBinaryResponse:
return 'BINARY_RESPONSE';
case MeshCoreConstants.pushPathDiscoveryResponse:
return 'PATH_DISCOVERY_RESPONSE';
case MeshCoreConstants.pushControlData:
return 'CONTROL_DATA';
case MeshCoreConstants.pushContactDeleted:
return 'CONTACT_DELETED';
case MeshCoreConstants.pushContactsFull:
return 'CONTACTS_FULL';
default:
return 'PUSH_UNKNOWN';
}

View File

@@ -59,6 +59,23 @@ class FrameParser {
return {};
}
/// Parse ContactMessage V3 response (firmware ver >= 3)
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
/// snr_dB = snr_scaled / 4.0
static Message parseContactMessageV3(BufferReader reader) {
reader.readInt8(); // snr scaled by 4 (ignored for now)
reader.readBytes(2); // reserved
return parseContactMessage(reader);
}
/// Parse ChannelMessage V3 response (firmware ver >= 3)
/// V3 prepends 3 bytes: [snr_scaled(int8)][reserved][reserved]
static Message parseChannelMessageV3(BufferReader reader) {
reader.readInt8(); // snr scaled by 4 (ignored for now)
reader.readBytes(2); // reserved
return parseChannelMessage(reader);
}
/// Parse ContactMessage response
static Message parseContactMessage(BufferReader reader) {
final pubKeyPrefix = reader.readBytes(6);

View File

@@ -868,11 +868,29 @@ class _MessageBubbleState extends State<MessageBubble> {
),
],
// Time for regular messages (not shown for SAR/drawing as it's already above)
if (!isSarMarker && !message.isDrawing)
if (!isSarMarker && !message.isDrawing) ...[
// Hop count indicator for received messages
if (!isOwnMessage && message.pathLen < 255) ...[
const SizedBox(width: 4),
Icon(
Icons.alt_route,
size: 11,
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
const SizedBox(width: 2),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
),
),
const SizedBox(width: 4),
],
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
const SizedBox(height: 8),