mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
fix: init tabs advert quick add
ref:
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user