From d5684c51c7b1c5709991a255505543da7498ce79 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 13 Mar 2026 19:24:42 +0100 Subject: [PATCH] Fix channel sync and logging --- lib/providers/app_provider.dart | 17 +- lib/providers/connection_provider.dart | 226 +++++++++--------- lib/providers/messages_provider.dart | 11 + lib/screens/home_screen.dart | 3 + lib/screens/messages_tab.dart | 68 +++++- ...connection_provider_channel_slot_test.dart | 20 ++ test/screens/messages_tab_test.dart | 107 +++++++++ 7 files changed, 335 insertions(+), 117 deletions(-) create mode 100644 test/screens/messages_tab_test.dart diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index cad7802..11e618c 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1849,11 +1849,11 @@ class AppProvider with ChangeNotifier { // Small delay to ensure contacts are fully loaded await Future.delayed(const Duration(milliseconds: 500)); - // Sync channels to get channel names - // In simple mode: only sync first 5 channels for faster startup - // In normal mode: sync all channels (up to device max) - const channelsToSync = 5; - debugPrint('📻 [AppProvider] Syncing channels (simple mode: max 5)...'); + // Sync all channels so slot assignment and channel state mirror the device. + final channelsToSync = connectionProvider.deviceInfo.maxChannels; + debugPrint( + '📻 [AppProvider] Syncing channels (max: ${channelsToSync ?? 40})...', + ); await connectionProvider.syncChannels(maxChannels: channelsToSync); debugPrint('✅ [AppProvider] Channel sync complete'); @@ -2918,9 +2918,10 @@ class AppProvider with ChangeNotifier { // Sync contacts await connectionProvider.getContacts(); - // Sync channels (respect simple mode settings) - const channelsToSync = 5; - await connectionProvider.syncChannels(maxChannels: channelsToSync); + // Sync all channels so refresh reflects the full device state. + await connectionProvider.syncChannels( + maxChannels: connectionProvider.deviceInfo.maxChannels, + ); // Messages are automatically synced via PUSH_CODE_MSG_WAITING events notifyListeners(); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 7c8fa67..6abcb19 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -853,6 +853,19 @@ class ConnectionProvider with ChangeNotifier { return requestedName.startsWith('#') && existingName == requestedName; } + @visibleForTesting + static int? firstAvailableChannelSlot({ + required Set occupiedIndices, + required int maxChannels, + }) { + for (int i = 1; i < maxChannels; i++) { + if (!occupiedIndices.contains(i)) { + return i; + } + } + return null; + } + bool _channelSlotIsOccupied(Object channel) { final channelName = (channel as dynamic).name as String?; final secret = (channel as dynamic).secret; @@ -934,6 +947,21 @@ class ConnectionProvider with ChangeNotifier { return _pendingDeletedChannelIndices.remove(channelIdx); } + Set _syncedOccupiedChannelIndices({required int maxChannels}) { + if (getChannelInfo == null) { + throw Exception('Channel state is unavailable'); + } + + final occupiedIndices = {}; + for (int i = 1; i < maxChannels; i++) { + final channel = getChannelInfo!(i); + if (channel != null && _channelSlotIsOccupied(channel)) { + occupiedIndices.add(i); + } + } + return occupiedIndices; + } + Future findNextEmptyChannelSlot() async { if (!_activeService.isConnected) { throw Exception('Not connected to device'); @@ -946,30 +974,16 @@ class ConnectionProvider with ChangeNotifier { final maxChannels = _deviceInfo.maxChannels ?? 40; final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0; - // Match meshcore-open: choose the first missing slot from the synced - // channel set and avoid speculative per-slot probing that can overwrite - // an existing channel when device state is delayed or transient. - if (getChannelInfo == null) { - throw Exception('Channel state is unavailable'); - } - - final usedIndices = {}; - for (int i = 1; i < maxChannels; i++) { - final channel = getChannelInfo!(i); - if (channel == null) { - continue; - } - - if (_channelSlotIsOccupied(channel)) { - usedIndices.add(i); - } - } - - for (int i = 1; i < maxChannels; i++) { - if (!usedIndices.contains(i)) { - debugPrint(' ✅ Found empty slot from synced channels: $i'); - return i; - } + final occupiedIndices = _syncedOccupiedChannelIndices( + maxChannels: maxChannels, + ); + final emptySlot = firstAvailableChannelSlot( + occupiedIndices: occupiedIndices, + maxChannels: maxChannels, + ); + if (emptySlot != null) { + debugPrint(' ✅ Found empty slot from synced channels: $emptySlot'); + return emptySlot; } debugPrint( @@ -1004,92 +1018,15 @@ class ConnectionProvider with ChangeNotifier { debugPrint('📻 [Provider] Creating new channel...'); debugPrint(' Name: $channelName'); - // Determine channel type final bool isHashChannel = channelName.startsWith('#'); - final maxChannels = _deviceInfo.maxChannels ?? 40; + final secretBytes = isHashChannel + ? _generateHashChannelSecret(channelName) + : _convertSecretToBytes(channelSecret); - // Refresh channel state before choosing a slot so we match meshcore-open's - // "pick first missing slot from the synced list" behavior. - await syncChannels(maxChannels: maxChannels); - - // Match meshcore-open behavior: - // - deterministic hash channels (#name) cannot be duplicated - // - private channels always use the next empty slot, even if the name matches - if (getChannelInfo != null) { - 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) { - if ( - isDuplicateChannelName( - requestedName: channelName, - existingName: existingName, - ) - ) { - debugPrint( - ' ⚠️ Hash channel "$channelName" already exists in slot $i', - ); - throw Exception( - 'Channel "$channelName" already exists. Hash channels cannot be duplicated.', - ); - } - } - } - } - } - - // Find next empty slot for any new channel. - final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0; - final emptySlot = await findNextEmptyChannelSlot(); - if (emptySlot == null) { - throw Exception( - 'All channel slots are in use (maximum $maxCustomChannels custom channels)', - ); - } - final slotIdx = emptySlot; - debugPrint(' Using empty slot: $slotIdx (new channel)'); - - // Generate secret - final List 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. Some devices accept the write but do not - // reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F). - try { - await _activeService.setChannel( - channelIdx: slotIdx, - channelName: channelName, - secret: secretBytes, - ); - } catch (e) { - if (_isChannelRefreshTimeoutError(e)) { - debugPrint( - '⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded', - ); - onChannelInfoReceived?.call( - slotIdx, - channelName, - Uint8List.fromList(secretBytes), - null, - ); - } else { - rethrow; - } - } - - debugPrint( - '✅ [Provider] Channel created successfully in slot $slotIdx', + await _createChannelWithSecretBytes( + channelName: channelName, + secretBytes: secretBytes, + isHashChannel: isHashChannel, ); } catch (e) { _error = 'Failed to create channel: $e'; @@ -1099,6 +1036,79 @@ class ConnectionProvider with ChangeNotifier { } } + Future _createChannelWithSecretBytes({ + required String channelName, + required List secretBytes, + required bool isHashChannel, + }) async { + final maxChannels = _deviceInfo.maxChannels ?? 40; + + await syncChannels(maxChannels: maxChannels); + + if (getChannelInfo != null) { + for (int i = 1; i < maxChannels; i++) { + final channel = getChannelInfo!(i); + if (channel == null) { + continue; + } + + final existingName = (channel as dynamic).name as String?; + if (existingName != null && + existingName.isNotEmpty && + isDuplicateChannelName( + requestedName: channelName, + existingName: existingName, + )) { + debugPrint( + ' ⚠️ Hash channel "$channelName" already exists in slot $i', + ); + throw Exception( + 'Channel "$channelName" already exists. Hash channels cannot be duplicated.', + ); + } + } + } + + final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0; + final slotIdx = await findNextEmptyChannelSlot(); + if (slotIdx == null) { + throw Exception( + 'All channel slots are in use (maximum $maxCustomChannels custom channels)', + ); + } + + debugPrint( + ' Channel type: ${isHashChannel ? "Hash channel (#)" : "Private channel"}', + ); + debugPrint(' Using empty slot: $slotIdx'); + + try { + await _activeService.setChannel( + channelIdx: slotIdx, + channelName: channelName, + secret: secretBytes, + ); + } catch (e) { + if (_isChannelRefreshTimeoutError(e)) { + debugPrint( + '⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded', + ); + onChannelInfoReceived?.call( + slotIdx, + channelName, + Uint8List.fromList(secretBytes), + null, + ); + } else { + rethrow; + } + } + + await syncChannels(maxChannels: maxChannels); + + debugPrint('✅ [Provider] Channel created successfully in slot $slotIdx'); + } + /// Delete a channel and remove it from the UI /// /// Clears the channel slot on the device and removes it from both diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index ea78167..a13f71d 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -246,6 +246,17 @@ class MessagesProvider with ChangeNotifier { ) .length; + int getUnreadCountForChannel(int channelIdx) => _messages + .where( + (message) => + message.isChannelMessage && + (message.channelIdx ?? 0) == channelIdx && + !message.isRead && + !message.isSentMessage && + !message.isSystemMessage, + ) + .length; + bool _isMessageForDestination(Message message, Contact contact) { if (message.isSystemMessage) return false; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 48e9f7f..6d1e2ac 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -753,6 +753,9 @@ class _HomeScreenState extends State switch (tab) { case _HomeTab.messages: return MessagesTab( + isActive: + _currentTab == _HomeTab.messages && + _lifecycleState == AppLifecycleState.resumed, onNavigateToMap: _isMapEnabled ? () => _navigateToTab(_HomeTab.map) : null, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index beeee50..7accc02 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart'; class MessagesTab extends StatefulWidget { final VoidCallback? onNavigateToMap; + final bool isActive; - const MessagesTab({super.key, this.onNavigateToMap}); + const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true}); @override State createState() => _MessagesTabState(); @@ -50,6 +51,7 @@ class _MessagesTabState extends State { static const int _maxContactMessageBytes = 156; static const int _maxChannelMessageBytes = 127; static const double _composerOverlayHeight = 148; + static const Duration _channelAutoReadDelay = Duration(seconds: 5); final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -57,6 +59,8 @@ class _MessagesTabState extends State { int _messageByteCount = 0; String? _highlightedMessageId; Timer? _highlightTimer; // Timer for clearing message highlight + Timer? _channelReadTimer; + String? _pendingChannelReadKey; TextEditingValue _lastComposerValue = const TextEditingValue(); bool _isMentionPickerOpen = false; bool _suppressMentionTrigger = false; @@ -119,6 +123,7 @@ class _MessagesTabState extends State { @override void dispose() { _highlightTimer?.cancel(); + _channelReadTimer?.cancel(); _voiceStreamSub?.cancel(); _voiceRecorder.dispose(); _textController.dispose(); @@ -127,6 +132,14 @@ class _MessagesTabState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant MessagesTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive != widget.isActive) { + _syncChannelAutoReadTimer(context.read()); + } + } + void _checkForNavigationRequest() { final messagesProvider = context.read(); final targetMessageId = messagesProvider.targetMessageId; @@ -1830,12 +1843,64 @@ class _MessagesTabState extends State { } void _markCurrentDestinationAsRead() { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; context.read().markDestinationAsRead( destinationType: _destinationType, contact: _selectedRecipient, ); } + void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) { + if (!widget.isActive || + _destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; + return; + } + + final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; + final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx); + + if (unreadCount <= 0) { + _channelReadTimer?.cancel(); + _pendingChannelReadKey = null; + return; + } + + final nextKey = '$channelIdx:$unreadCount'; + if (_pendingChannelReadKey == nextKey && + _channelReadTimer?.isActive == true) { + return; + } + + _channelReadTimer?.cancel(); + _pendingChannelReadKey = nextKey; + _channelReadTimer = Timer(_channelAutoReadDelay, () { + if (!mounted || !widget.isActive) { + return; + } + + if (_destinationType != + MessageDestinationPreferences.destinationTypeChannel) { + return; + } + + final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0; + if (currentChannelIdx != channelIdx) { + return; + } + + final latestProvider = context.read(); + if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) { + return; + } + + _markCurrentDestinationAsRead(); + }); + } + List _getFilteredMessages(MessagesProvider messagesProvider) { // Get all recent messages final allMessages = messagesProvider.getRecentMessages(count: 100); @@ -1928,6 +1993,7 @@ class _MessagesTabState extends State { Widget build(BuildContext context) { return Consumer( builder: (context, messagesProvider, child) { + _syncChannelAutoReadTimer(messagesProvider); final messages = _getFilteredMessages(messagesProvider); final bottomInset = MediaQuery.of(context).viewPadding.bottom; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; diff --git a/test/providers/connection_provider_channel_slot_test.dart b/test/providers/connection_provider_channel_slot_test.dart index 7996905..bc986e3 100644 --- a/test/providers/connection_provider_channel_slot_test.dart +++ b/test/providers/connection_provider_channel_slot_test.dart @@ -45,5 +45,25 @@ void main() { isFalse, ); }); + + test('finds first missing custom channel slot', () { + expect( + ConnectionProvider.firstAvailableChannelSlot( + occupiedIndices: {1, 2, 4}, + maxChannels: 6, + ), + 3, + ); + }); + + test('returns null when all custom channel slots are occupied', () { + expect( + ConnectionProvider.firstAvailableChannelSlot( + occupiedIndices: {1, 2, 3, 4}, + maxChannels: 5, + ), + isNull, + ); + }); }); } diff --git a/test/screens/messages_tab_test.dart b/test/screens/messages_tab_test.dart new file mode 100644 index 0000000..a93466f --- /dev/null +++ b/test/screens/messages_tab_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/screens/messages_tab.dart'; +import 'package:meshcore_sar_app/services/voice_codec_service.dart'; +import 'package:meshcore_sar_app/services/voice_player_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('marks public channel as read after 5 seconds of viewing', ( + tester, + ) async { + final connectionProvider = ConnectionProvider(); + final contactsProvider = ContactsProvider(); + final messagesProvider = MessagesProvider(); + final mapProvider = MapProvider(); + final drawingProvider = DrawingProvider(); + final channelsProvider = ChannelsProvider()..initializePublicChannel(); + final voiceProvider = VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ); + final imageProvider = ip.ImageProvider(); + final appProvider = AppProvider( + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + messagesProvider: messagesProvider, + drawingProvider: drawingProvider, + channelsProvider: channelsProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, + ); + + messagesProvider.addMessage( + Message( + id: 'public-unread', + messageType: MessageType.channel, + pathLen: 1, + textType: MessageTextType.plain, + senderTimestamp: 1700000100, + text: 'Unread on public channel', + receivedAt: DateTime.now(), + channelIdx: 0, + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connectionProvider), + ChangeNotifierProvider.value(value: contactsProvider), + ChangeNotifierProvider.value(value: messagesProvider), + ChangeNotifierProvider.value(value: mapProvider), + ChangeNotifierProvider.value(value: drawingProvider), + ChangeNotifierProvider.value(value: channelsProvider), + ChangeNotifierProvider.value(value: voiceProvider), + ChangeNotifierProvider.value(value: imageProvider), + ChangeNotifierProvider.value(value: appProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MessagesTab(isActive: true)), + ), + ), + ); + + await tester.pump(); + + expect(messagesProvider.unreadCount, 1); + + await tester.pump(const Duration(seconds: 4)); + expect(messagesProvider.unreadCount, 1); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + + expect(messagesProvider.unreadCount, 0); + + appProvider.dispose(); + voiceProvider.dispose(); + imageProvider.dispose(); + drawingProvider.dispose(); + mapProvider.dispose(); + messagesProvider.dispose(); + contactsProvider.dispose(); + connectionProvider.dispose(); + channelsProvider.dispose(); + }); +}