mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 09:20:29 +00:00
feat: Add support for hash and private channels with corresponding UI updates
- Introduced new localization strings for channel types and their descriptions in Italian, German, Spanish, French, Croatian, Slovenian, and English. - Updated the `AppProvider` to handle channel info reception, including deletion of channels and updating the UI accordingly. - Enhanced `ChannelsProvider` to support channel removal and notify listeners. - Modified `ConnectionProvider` to include methods for checking empty channel slots and deleting channels. - Updated BLE service methods to handle channel deletion and verification. - Improved the `AddChannelDialog` to differentiate between hash and private channels, including dynamic UI elements based on channel type. - Added delete functionality for channels in the `ContactTile` widget with confirmation dialogs. - Adjusted permission request dialog behavior to allow dismissing by tapping outside.
This commit is contained in:
@@ -154,15 +154,84 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
};
|
||||
|
||||
// Setup callback for ConnectionProvider to query channel info
|
||||
connectionProvider.getChannelInfo = (int channelIdx) {
|
||||
return channelsProvider.getChannel(channelIdx);
|
||||
};
|
||||
|
||||
// When channel info is received
|
||||
connectionProvider.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
|
||||
channelsProvider.addOrUpdateChannel(
|
||||
index: channelIdx,
|
||||
name: channelName,
|
||||
secret: secret,
|
||||
flags: flags,
|
||||
);
|
||||
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName" (isHashChannel: ${channelName.startsWith('#')})');
|
||||
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
|
||||
@@ -492,12 +561,18 @@ class AppProvider with ChangeNotifier {
|
||||
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
|
||||
|
||||
/// Refresh data (contacts only - messages are handled via events)
|
||||
/// Refresh data (contacts and channels - messages are handled via events)
|
||||
Future<void> refresh() async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -50,6 +50,20 @@ class ChannelsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a channel by index
|
||||
void removeChannel(int index) {
|
||||
if (_channels.containsKey(index)) {
|
||||
_channels.remove(index);
|
||||
|
||||
// If the deleted channel was selected, switch to public channel
|
||||
if (_selectedChannelIndex == index) {
|
||||
_selectedChannelIndex = 0;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a channel for sending messages
|
||||
void selectChannel(int index) {
|
||||
if (_channels.containsKey(index) || index == 0) {
|
||||
|
||||
@@ -865,6 +865,47 @@ class ConnectionProvider with ChangeNotifier {
|
||||
///
|
||||
/// Returns the channel index of the first empty slot, or null if all slots are in use.
|
||||
/// Skips slot 0 (reserved for Public Channel).
|
||||
/// Callback to get channel info for empty slot detection
|
||||
/// This should be set by AppProvider to query ChannelsProvider
|
||||
Function(int channelIdx)? getChannelInfo;
|
||||
|
||||
/// Check if a specific channel slot is empty
|
||||
Future<bool> isChannelSlotEmpty(int channelIdx) async {
|
||||
if (!_bleService.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if we already have info about this channel
|
||||
if (getChannelInfo != null) {
|
||||
final channel = getChannelInfo!(channelIdx);
|
||||
if (channel != null) {
|
||||
final channelName = (channel as dynamic).name as String?;
|
||||
return channelName == null || channelName.isEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// If not cached, query the device
|
||||
await _bleService.getChannel(channelIdx);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Check again after query
|
||||
if (getChannelInfo != null) {
|
||||
final channel = getChannelInfo!(channelIdx);
|
||||
if (channel != null) {
|
||||
final channelName = (channel as dynamic).name as String?;
|
||||
return channelName == null || channelName.isEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no info, assume it's empty
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> findNextEmptyChannelSlot() async {
|
||||
if (!_bleService.isConnected) {
|
||||
throw Exception('Not connected to device');
|
||||
@@ -873,26 +914,34 @@ class ConnectionProvider with ChangeNotifier {
|
||||
try {
|
||||
debugPrint('🔍 [Provider] Finding next empty channel slot...');
|
||||
|
||||
// Query channels 1-39 (skip 0 = public channel)
|
||||
// maxChannels from device info, or default to 40
|
||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||
|
||||
// Check each slot starting from 1 (skip 0 = public channel)
|
||||
for (int i = 1; i < maxChannels; i++) {
|
||||
await _bleService.getChannel(i);
|
||||
// Small delay to allow response to arrive
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
// First check cache
|
||||
if (getChannelInfo != null) {
|
||||
final channel = getChannelInfo!(i);
|
||||
if (channel != null) {
|
||||
final channelName = (channel as dynamic).name as String?;
|
||||
if (channelName != null && channelName.isNotEmpty) {
|
||||
debugPrint(' ⏭️ Slot $i occupied: "$channelName"');
|
||||
continue; // Skip occupied slots
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this channel is empty via channels provider
|
||||
// (The BLE response handler calls onChannelInfoReceived callback
|
||||
// which updates the channels provider)
|
||||
// For now, we'll return the first slot since the channels provider
|
||||
// doesn't expose empty slot info. This can be improved later.
|
||||
// Slot appears empty in cache, verify by querying device
|
||||
debugPrint(' 🔍 Checking slot $i...');
|
||||
final isEmpty = await isChannelSlotEmpty(i);
|
||||
if (isEmpty) {
|
||||
debugPrint(' ✅ Found empty slot: $i');
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// For simplicity, return the first slot after public channel
|
||||
// A more robust implementation would check which slots are actually empty
|
||||
// by querying the channels provider
|
||||
return 1;
|
||||
|
||||
debugPrint(' ❌ All slots (1-${maxChannels - 1}) are in use');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [Provider] Failed to find empty channel slot: $e');
|
||||
rethrow;
|
||||
@@ -921,17 +970,63 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint('📻 [Provider] Creating new channel...');
|
||||
debugPrint(' Name: $channelName');
|
||||
|
||||
// Find next empty slot
|
||||
final slotIdx = await findNextEmptyChannelSlot();
|
||||
if (slotIdx == null) {
|
||||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
||||
// Determine channel type
|
||||
final bool isHashChannel = channelName.startsWith('#');
|
||||
|
||||
// Check for duplicate channels
|
||||
int? existingSlot;
|
||||
if (getChannelInfo != null) {
|
||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||
for (int i = 1; i < maxChannels; i++) {
|
||||
final channel = getChannelInfo!(i);
|
||||
if (channel != null) {
|
||||
final existingName = (channel as dynamic).name as String?;
|
||||
if (existingName != null && existingName.isNotEmpty) {
|
||||
// For hash channels (#name), check exact match to prevent duplicates
|
||||
if (isHashChannel && existingName == channelName) {
|
||||
debugPrint(' ⚠️ Hash channel "$channelName" already exists in slot $i');
|
||||
throw Exception('Channel "$channelName" already exists. Hash channels cannot be duplicated.');
|
||||
}
|
||||
// For private channels, check name match to allow overwrite
|
||||
else if (!isHashChannel && existingName == channelName) {
|
||||
debugPrint(' ℹ️ Private channel "$channelName" found in slot $i - will overwrite');
|
||||
existingSlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(' Using slot: $slotIdx');
|
||||
// Determine slot to use
|
||||
final int slotIdx;
|
||||
if (existingSlot != null) {
|
||||
// Overwrite existing private channel
|
||||
slotIdx = existingSlot;
|
||||
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
||||
} else {
|
||||
// Find next empty slot for new channel
|
||||
final emptySlot = await findNextEmptyChannelSlot();
|
||||
if (emptySlot == null) {
|
||||
throw Exception('All channel slots are in use (maximum 39 custom channels)');
|
||||
}
|
||||
slotIdx = emptySlot;
|
||||
debugPrint(' Using empty slot: $slotIdx (new channel)');
|
||||
}
|
||||
|
||||
// Convert ASCII secret to 16-byte key using MD5
|
||||
final secretBytes = _convertSecretToBytes(channelSecret);
|
||||
debugPrint(' Secret converted to 16-byte key');
|
||||
// Generate secret
|
||||
final List<int> secretBytes;
|
||||
if (isHashChannel) {
|
||||
// Hash channel: auto-generate secret from name using SHA256
|
||||
debugPrint(' Channel type: Hash channel (#)');
|
||||
secretBytes = _generateHashChannelSecret(channelName);
|
||||
debugPrint(' Secret auto-generated from channel name using SHA256');
|
||||
} else {
|
||||
// Private channel: use explicit secret with MD5
|
||||
debugPrint(' Channel type: Private channel');
|
||||
secretBytes = _convertSecretToBytes(channelSecret);
|
||||
debugPrint(' Secret converted to 16-byte key using MD5');
|
||||
}
|
||||
|
||||
// Send CMD_SET_CHANNEL to radio
|
||||
await _bleService.setChannel(
|
||||
@@ -940,7 +1035,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
secret: secretBytes,
|
||||
);
|
||||
|
||||
debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx');
|
||||
debugPrint('✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx');
|
||||
|
||||
// Small delay to allow the response to propagate
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -956,7 +1051,56 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a channel and remove it from the UI
|
||||
///
|
||||
/// Clears the channel slot on the device and removes it from both
|
||||
/// ChannelsProvider and ContactsProvider. The slot becomes available for reuse.
|
||||
///
|
||||
/// [channelIdx] - Channel slot index (1-39). Channel 0 (public) cannot be deleted.
|
||||
///
|
||||
/// Throws an exception if the channel cannot be deleted or if channel 0 is specified.
|
||||
Future<void> deleteChannel(int channelIdx) async {
|
||||
if (!_bleService.isConnected) {
|
||||
throw Exception('Not connected to device');
|
||||
}
|
||||
|
||||
if (channelIdx == 0) {
|
||||
throw Exception('Cannot delete the public channel');
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🗑️ [Provider] Deleting channel in slot $channelIdx...');
|
||||
|
||||
// Delete channel on device (sets empty name and zeroed secret)
|
||||
await _bleService.deleteChannel(channelIdx);
|
||||
|
||||
debugPrint('✅ [Provider] Channel deleted successfully from slot $channelIdx');
|
||||
|
||||
// Small delay to allow the response to propagate
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Refresh channels to update UI
|
||||
// The empty channel will trigger removal via onChannelInfoReceived callback
|
||||
await _bleService.getChannel(channelIdx);
|
||||
} catch (e) {
|
||||
_error = 'Failed to delete channel: $e';
|
||||
debugPrint('❌ [Provider] Channel deletion failed: $e');
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate secret for hash channel using SHA256
|
||||
/// Same algorithm as Channel model for consistency
|
||||
/// Python equivalent: hashlib.sha256(channel_name.encode()).digest()[0:16]
|
||||
List<int> _generateHashChannelSecret(String channelName) {
|
||||
final bytes = utf8.encode(channelName);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.bytes.sublist(0, 16);
|
||||
}
|
||||
|
||||
/// Convert ASCII secret string to 16-byte key using MD5 hash
|
||||
/// Used for private channels with explicit secrets
|
||||
List<int> _convertSecretToBytes(String asciiSecret) {
|
||||
// Use MD5 hash to convert any length ASCII string to exactly 16 bytes
|
||||
// This provides a deterministic and secure way to generate channel keys
|
||||
|
||||
@@ -194,6 +194,8 @@ 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)}...)');
|
||||
|
||||
// Don't add contacts that match our device's public key
|
||||
if (devicePublicKey != null &&
|
||||
_publicKeysMatch(contact.publicKey, devicePublicKey)) {
|
||||
@@ -205,6 +207,7 @@ 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}');
|
||||
|
||||
Contact updatedContact;
|
||||
if (isNewContact) {
|
||||
@@ -242,8 +245,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
debugPrint(' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}');
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
debugPrint(' 🔔 notifyListeners() called');
|
||||
}
|
||||
|
||||
/// Compare two public keys for equality
|
||||
|
||||
Reference in New Issue
Block a user