From b42328bd82643e03f05dfe8ac17f01f45678e1c6 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 14 Mar 2026 10:24:14 +0100 Subject: [PATCH] Ensure contact sync waits --- lib/providers/app_provider.dart | 5 +- lib/providers/connection_provider.dart | 51 ++++++++++++++++- lib/providers/contacts_provider.dart | 40 ++++++++----- lib/screens/contacts_tab.dart | 69 +++++++++++++---------- lib/services/contact_storage_service.dart | 29 ++++++++++ 5 files changed, 143 insertions(+), 51 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 615d39f..77e488a 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -1895,12 +1895,9 @@ class AppProvider with ChangeNotifier { // Get battery and storage information await connectionProvider.getBatteryAndStorage(); - // Load contacts + // Load contacts (waits for device to finish sending all contacts) await connectionProvider.getContacts(); - // Small delay to ensure contacts are fully loaded - await Future.delayed(const Duration(milliseconds: 500)); - // Sync all channels so slot assignment and channel state mirror the device. final channelsToSync = connectionProvider.deviceInfo.maxChannels; debugPrint( diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 9f6bada..439b16d 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -130,6 +130,9 @@ class ConnectionProvider with ChangeNotifier { // Completer to wait for response before sending next sync request Completer? _syncResponseCompleter; + // Completer to wait for contacts sync to finish + Completer? _contactsSyncCompleter; + // Lightweight guards for other commands that can be double-tapped bool _isLoginInProgress = false; DateTime? _lastLoginRequestedAt; @@ -291,6 +294,10 @@ class ConnectionProvider with ChangeNotifier { service.onContactsComplete = (contacts) { debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); + if (_contactsSyncCompleter != null && + !_contactsSyncCompleter!.isCompleted) { + _contactsSyncCompleter!.complete(); + } onContactsComplete?.call(contacts); }; @@ -583,6 +590,9 @@ class ConnectionProvider with ChangeNotifier { await stopScan(); } + // Ensure we route commands to BLE, not a stale TCP service. + _connectionMode = ConnectionMode.ble; + _deviceInfo = _deviceInfo.copyWith( deviceId: device.remoteId.toString(), deviceName: device.platformName.isNotEmpty @@ -591,6 +601,7 @@ class ConnectionProvider with ChangeNotifier { connectionState: ConnectionState.connecting, ); _error = null; + _resetSyncState(); debugPrint('✅ [Provider] Device info updated to connecting state'); notifyListeners(); @@ -648,6 +659,7 @@ class ConnectionProvider with ChangeNotifier { } _tcpHost = null; _connectionMode = ConnectionMode.ble; + _resetSyncState(); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _roomLoginManager.clearRoomLoginStates(); _pingTracker.clearAll(); @@ -670,6 +682,7 @@ class ConnectionProvider with ChangeNotifier { await _bleService.disconnect(); + _resetSyncState(); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _roomLoginManager.clearRoomLoginStates(); _pingTracker.clearAll(); @@ -678,6 +691,24 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); } + /// Reset message sync state so the next connect/reconnect can sync cleanly. + void _resetSyncState() { + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { + _syncResponseCompleter!.complete(false); + } + _syncResponseCompleter = null; + if (_contactsSyncCompleter != null && + !_contactsSyncCompleter!.isCompleted) { + _contactsSyncCompleter!.complete(); + } + _contactsSyncCompleter = null; + _isSyncingMessages = false; + _syncRequestedWhileBusy = false; + _noMoreMessages = false; + _pendingAutomaticMessageSync = false; + } + /// Cancel ongoing reconnection attempts /// This is useful when the user wants to manually disconnect during reconnection void cancelReconnection() { @@ -719,7 +750,10 @@ class ConnectionProvider with ChangeNotifier { return _messageDeliveryTracker.getDiagnostics(); } - /// Get contacts from device + /// Get contacts from device. + /// + /// Waits for the device to finish sending all contacts (up to 5 s timeout) + /// so callers don't need an arbitrary delay. Future getContacts() async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -728,10 +762,21 @@ class ConnectionProvider with ChangeNotifier { } try { + _contactsSyncCompleter = Completer(); await _activeService.getContacts(); + await _contactsSyncCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + debugPrint( + '⚠️ [Provider] Contacts sync timeout - proceeding without full list', + ); + }, + ); } catch (e) { _error = 'Failed to get contacts: $e'; notifyListeners(); + } finally { + _contactsSyncCompleter = null; } } @@ -890,9 +935,8 @@ class ConnectionProvider with ChangeNotifier { } } - // If not cached, query the device + // If not cached, query the device (awaits the BLE response) await _activeService.getChannel(channelIdx); - await Future.delayed(const Duration(milliseconds: 100)); // Check again after query if (getChannelInfo != null) { @@ -2370,6 +2414,7 @@ class ConnectionProvider with ChangeNotifier { void dispose() { _rxActivityTimer?.cancel(); _txActivityTimer?.cancel(); + _stopAckCleanupTimer(); _bleService.dispose(); _tcpService?.dispose(); _sseServer.stopServer(); diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 979222e..d53c7c7 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -64,6 +64,8 @@ class ContactsProvider with ChangeNotifier { final Map _pendingAdverts = {}; final ContactStorageService _storageService = ContactStorageService(); bool _isInitialized = false; + bool _isPersisting = false; + bool _persistRequested = false; // Add default public channel on initialization ContactsProvider() { @@ -202,19 +204,27 @@ class ContactsProvider with ChangeNotifier { } } - /// Persist contacts to storage (async, non-blocking) + /// Persist contacts to storage (async, non-blocking, coalescing). Future _persistContacts() async { + _persistRequested = true; + if (_isPersisting) return; + _isPersisting = true; try { - // Don't persist the public channel pseudo-contact (all zeros key) - const publicChannelKey = - '0000000000000000000000000000000000000000000000000000000000000000'; - final contactsToSave = _contacts.entries - .where((entry) => entry.key != publicChannelKey) - .map((entry) => entry.value) - .toList(); - await _storageService.saveContacts(contactsToSave); + while (_persistRequested) { + _persistRequested = false; + // Don't persist the public channel pseudo-contact (all zeros key) + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; + final contactsToSave = _contacts.entries + .where((entry) => entry.key != publicChannelKey) + .map((entry) => entry.value) + .toList(); + await _storageService.saveContacts(contactsToSave); + } } catch (e) { debugPrint('❌ [ContactsProvider] Error persisting contacts: $e'); + } finally { + _isPersisting = false; } } @@ -503,7 +513,6 @@ class ContactsProvider with ChangeNotifier { if (existingContact == null) { var newContact = incomingContact.copyWith( isNew: true, - nameOverride: existingContact?.nameOverride, telemetry: mergedTelemetry, outPathLen: retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, @@ -908,7 +917,7 @@ class ContactsProvider with ChangeNotifier { .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); - for (final contact in contacts) { + for (final contact in _contacts.values) { if (contact.publicKeyHex.startsWith(prefixHex)) { return contact; } @@ -1092,10 +1101,10 @@ class ContactsProvider with ChangeNotifier { /// Find contact by name Contact? findContactByName(String name) { - return contacts.firstWhere( - (c) => c.advName == name, - orElse: () => contacts.first, - ); + for (final contact in _contacts.values) { + if (contact.advName == name) return contact; + } + return null; } /// Get contacts with low battery @@ -1144,6 +1153,7 @@ class ContactsProvider with ChangeNotifier { void clearContacts() { _contacts.clear(); _pendingAdverts.clear(); + _ensurePublicChannelExists(); _persistContacts(); notifyListeners(); } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index ecd23cf..fea4164 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -669,25 +669,10 @@ class _ContactsTabState extends State { _showSavedGroupsForSection(ContactSection.channels) ? savedChannelGroups : const <_RenderedSavedGroup>[]; - final showTeamMembersSection = - allChatContacts.isNotEmpty && - (!_sectionHasActiveFilter(ContactSection.teamMembers) || - chatContacts.isNotEmpty || - visibleSavedTeamGroups.isNotEmpty); - final showRepeatersSection = - allRepeaters.isNotEmpty && - (!_sectionHasActiveFilter(ContactSection.repeaters) || - repeaters.isNotEmpty || - visibleSavedRepeaterGroups.isNotEmpty); - final showRoomsSection = - allRooms.isNotEmpty && - (!_sectionHasActiveFilter(ContactSection.rooms) || - rooms.isNotEmpty || - visibleSavedRoomGroups.isNotEmpty); - final showChannelsSection = - !_sectionHasActiveFilter(ContactSection.channels) || - filteredChannels.isNotEmpty || - visibleSavedChannelGroups.isNotEmpty; + final showTeamMembersSection = allChatContacts.isNotEmpty; + final showRepeatersSection = allRepeaters.isNotEmpty; + final showRoomsSection = allRooms.isNotEmpty; + final showChannelsSection = allChannels.isNotEmpty; final pendingAdverts = contactsProvider.pendingAdverts; _schedulePendingAdvertResolution(pendingAdverts, connectionProvider); @@ -761,12 +746,16 @@ class _ContactsTabState extends State { visibleSavedTeamGroups, ContactSection.teamMembers, ), - ..._buildContactSectionItems( - _excludeGroupedContacts( - chatContacts, - visibleSavedTeamGroups, + if (chatContacts.isEmpty && + _sectionHasActiveFilter(ContactSection.teamMembers)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + chatContacts, + visibleSavedTeamGroups, + ), ), - ), const Divider(height: 32), ], @@ -799,7 +788,10 @@ class _ContactsTabState extends State { visibleSavedRepeaterGroups, ContactSection.repeaters, ), - if (showRepeatersOthersGroup) + if (repeaters.isEmpty && + _sectionHasActiveFilter(ContactSection.repeaters)) + _buildNoFilterResults(context) + else if (showRepeatersOthersGroup) _InferredContactGroupCard( label: 'Others', contacts: ungroupedRepeaters, @@ -836,9 +828,13 @@ class _ContactsTabState extends State { visibleSavedRoomGroups, ContactSection.rooms, ), - ..._buildContactSectionItems( - _excludeGroupedContacts(rooms, visibleSavedRoomGroups), - ), + if (rooms.isEmpty && + _sectionHasActiveFilter(ContactSection.rooms)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts(rooms, visibleSavedRoomGroups), + ), const Divider(height: 32), ], @@ -879,7 +875,10 @@ class _ContactsTabState extends State { visibleSavedChannelGroups, ContactSection.channels, ), - if (filteredChannels.isNotEmpty) ...[ + if (filteredChannels.isEmpty && + _sectionHasActiveFilter(ContactSection.channels)) + _buildNoFilterResults(context) + else ...[ ..._excludeGroupedContacts( filteredChannels, visibleSavedChannelGroups, @@ -921,6 +920,18 @@ class _ContactsTabState extends State { ); } + Widget _buildNoFilterResults(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + child: Text( + 'No matches', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + List _buildContactSectionItems( List contacts, { bool compact = false, diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index d2e3e74..44dbde4 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -167,6 +167,15 @@ class ContactStorageService { 'telemetry': contact.telemetry != null ? _telemetryToJson(contact.telemetry!) : null, + 'advertHistory': contact.advertHistory + .map( + (point) => { + 'lat': point.location.latitude, + 'lon': point.location.longitude, + 'tsMillis': point.timestamp.millisecondsSinceEpoch, + }, + ) + .toList(), }; } @@ -190,6 +199,7 @@ class ContactStorageService { telemetry: json['telemetry'] != null ? _telemetryFromJson(json['telemetry'] as Map) : null, + advertHistory: _advertHistoryFromJson(json['advertHistory']), ); } catch (e) { debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e'); @@ -242,6 +252,25 @@ class ContactStorageService { } } + List _advertHistoryFromJson(dynamic json) { + if (json is! List) return []; + final result = []; + for (final item in json) { + if (item is! Map) continue; + final lat = item['lat']; + final lon = item['lon']; + final tsMillis = item['tsMillis']; + if (lat is! num || lon is! num || tsMillis is! int) continue; + result.add( + AdvertLocation( + location: LatLng(lat.toDouble(), lon.toDouble()), + timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis), + ), + ); + } + return result; + } + Map _contactGroupToJson(SavedContactGroup group) { return { 'id': group.id,