From 6978f244406b064ede01b6f9f11f4245636d546a Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 24 Mar 2026 19:07:44 +0100 Subject: [PATCH] fix: Merge self replay and refresh self telemetry --- ios/fastlane/report.xml | 8 +- lib/providers/messages_provider.dart | 31 ++++++- lib/providers/sensors_provider.dart | 14 +-- lib/screens/sensors_tab.dart | 89 +++++++++++++++---- pubspec.yaml | 2 +- ...messages_provider_retransmission_test.dart | 50 ++++++++--- test/providers/sensors_provider_test.dart | 13 ++- 7 files changed, 159 insertions(+), 48 deletions(-) diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 8dfaae6..8100447 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 868c297..cc7dbbc 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -666,7 +666,23 @@ class MessagesProvider with ChangeNotifier { final matchingSentReplayIndex = _findMatchingSentReplayIndex(finalMessage); if (matchingSentReplayIndex != -1) { - _clearChannelSendWarning(_messages[matchingSentReplayIndex].id); + final existingId = _messages[matchingSentReplayIndex].id; + _clearChannelSendWarning(existingId); + if (contactLocationSnapshot != null) { + _messageContactLocations[existingId] = contactLocationSnapshot; + } + _messageReceptionDetails[existingId] = + MessageReceptionDetails.mergeDuplicate( + existing: _messageReceptionDetails[existingId], + incoming: receptionDetailsSnapshot, + ); + final existingMessage = _messages[matchingSentReplayIndex]; + _messages[matchingSentReplayIndex] = existingMessage.copyWith( + pathLen: finalMessage.pathLen > 0 ? finalMessage.pathLen : existingMessage.pathLen, + pathBytes: finalMessage.pathBytes ?? existingMessage.pathBytes, + ); + _persistMessages(); + return; } _messages.add(finalMessage); @@ -1214,9 +1230,7 @@ class MessagesProvider with ChangeNotifier { for (final message in messages) { final occurrenceCount = _messageOccurrenceCount(message); final existingIndex = entries.indexWhere( - (entry) => - entry.message.text == message.text && - _matchesDuplicateScope(entry.message, message), + (entry) => _shouldCollapseDisplayMessage(entry.message, message), ); if (existingIndex == -1) { @@ -1234,6 +1248,15 @@ class MessagesProvider with ChangeNotifier { return entries; } + bool _shouldCollapseDisplayMessage(Message existing, Message message) { + if (existing.isSentMessage || message.isSentMessage) { + return false; + } + + return existing.text == message.text && + _matchesDuplicateScope(existing, message); + } + int _messageOccurrenceCount(Message message) => _messageReceptionDetails[message.id]?.receivedCopies ?? 1; diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index c1768a8..46b0024 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -14,7 +14,7 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } class SensorsProvider with ChangeNotifier { static const Duration _successStateRetention = Duration(minutes: 1); - static const int selfAutoRefreshMinutes = 1; + static const Duration selfAutoRefreshInterval = Duration(seconds: 30); static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _fieldSpanKey = 'sensor_field_spans'; @@ -374,18 +374,18 @@ class SensorsProvider with ChangeNotifier { return List.unmodifiable(dueKeys); } - bool _isRefreshDue( + bool _isRefreshDueForInterval( String publicKeyHex, { - required int minutes, + required Duration interval, required DateTime refreshTime, }) { - if (minutes <= 0) { + if (interval <= Duration.zero) { return false; } final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex]; return lastRefreshAt == null || - refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes); + refreshTime.difference(lastRefreshAt) >= interval; } Future toggleMetric( @@ -769,9 +769,9 @@ class SensorsProvider with ChangeNotifier { final self = selfContact(contactsProvider, connectionProvider); if (self != null && !dueKeys.contains(self.publicKeyHex) && - _isRefreshDue( + _isRefreshDueForInterval( self.publicKeyHex, - minutes: selfAutoRefreshMinutes, + interval: selfAutoRefreshInterval, refreshTime: refreshTime, )) { dueKeys.insert(0, self.publicKeyHex); diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 191d931..506e0f2 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; +import '../providers/map_provider.dart'; import '../providers/sensors_provider.dart'; import '../widgets/sensors/bthome_met_history_sheet.dart'; import '../widgets/sensors/sensor_telemetry_card.dart'; @@ -21,7 +22,10 @@ class SensorsTab extends StatefulWidget { } class _SensorsTabState extends State { + static const Duration _autoRefreshTickInterval = Duration(seconds: 30); Timer? _minuteTicker; + final Map _lastCenteredTelemetryAtBySensor = + {}; @override void initState() { @@ -61,22 +65,8 @@ class _SensorsTabState extends State { return; } - final now = DateTime.now(); - final nextMinute = DateTime( - now.year, - now.month, - now.day, - now.hour, - now.minute + 1, - ); - final delay = nextMinute.difference(now); - - _minuteTicker = Timer(delay, () { - if (!mounted) return; + _minuteTicker = Timer.periodic(_autoRefreshTickInterval, (_) { unawaited(_handleMinuteTick()); - _minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) { - unawaited(_handleMinuteTick()); - }); }); } @@ -225,6 +215,65 @@ class _SensorsTabState extends State { ); } + void _maybeCenterMapOnTelemetryUpdate( + Iterable sensorKeys, { + required SensorsProvider sensorsProvider, + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) { + if (!widget.isActive) { + return; + } + + Contact? latestContact; + DateTime? latestTimestamp; + + for (final key in sensorKeys) { + final contact = sensorsProvider.contactForDisplay( + key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final timestamp = contact?.telemetry?.timestamp; + final location = contact?.displayLocation; + if (contact == null || timestamp == null || location == null) { + continue; + } + + final previousTimestamp = _lastCenteredTelemetryAtBySensor[key]; + if (previousTimestamp != null && !timestamp.isAfter(previousTimestamp)) { + continue; + } + + if (latestTimestamp == null || timestamp.isAfter(latestTimestamp)) { + latestContact = contact; + latestTimestamp = timestamp; + } + } + + if (latestContact == null || latestTimestamp == null) { + return; + } + + _lastCenteredTelemetryAtBySensor[latestContact.publicKeyHex] = + latestTimestamp; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.isActive) { + return; + } + + final location = latestContact!.displayLocation; + if (location == null) { + return; + } + + context.read().navigateToLocation( + location: location, + zoom: 15.0, + ); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -247,6 +296,16 @@ class _SensorsTabState extends State { contactsProvider: contactsProvider, connectionProvider: connectionProvider, ); + final displayKeys = [ + ...?selfDisplayKey == null ? null : [selfDisplayKey], + ...watchedKeys, + ]; + _maybeCenterMapOnTelemetryUpdate( + displayKeys, + sensorsProvider: sensorsProvider, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); final hasAnyCards = selfDisplayKey != null || watchedKeys.isNotEmpty; diff --git a/pubspec.yaml b/pubspec.yaml index 176e1e3..1a53511 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0323.3+42 +version: 2026.0324.1+43 environment: sdk: ^3.9.2 diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index 02aaa4d..f4540d4 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -239,11 +239,12 @@ void main() { ); expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse); - expect(provider.messages, hasLength(2)); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-warn-replay')); }); }); - test('channel replay is kept separate for self sender within repeat window', () { + test('channel replay merges into sent message for self sender within repeat window', () { final provider = MessagesProvider(); provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; provider.addSentMessage( @@ -259,10 +260,9 @@ void main() { ), ); - expect(provider.messages, hasLength(2)); - expect(provider.messages.first.id, equals('c-echo')); - expect(provider.messages.last.id, equals('c-echo-incoming')); - expect(provider.messages.last.senderName, equals('dz0ny (SI)')); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-echo')); + expect(provider.messages.single.pathLen, equals(1)); }); test( @@ -309,7 +309,7 @@ void main() { expect(provider.messages, hasLength(2)); }); - test('channel replay stays separate using lazily resolved self name', () { + test('channel replay merges using lazily resolved self name', () { final provider = MessagesProvider(); provider.addSentMessage( _buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400), @@ -325,12 +325,11 @@ void main() { ), ); - expect(provider.messages, hasLength(2)); - expect(provider.messages.first.id, equals('c-lazy')); - expect(provider.messages.last.id, equals('c-lazy-incoming')); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-lazy')); }); - test('channel replay stays separate for meshcore-prefixed self sender name', () { + test('channel replay merges for meshcore-prefixed self sender name', () { final provider = MessagesProvider(); provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)'; provider.addSentMessage( @@ -346,9 +345,8 @@ void main() { ), ); - expect(provider.messages, hasLength(2)); - expect(provider.messages.first.id, equals('c-prefix')); - expect(provider.messages.last.id, equals('c-prefix-incoming')); + expect(provider.messages, hasLength(1)); + expect(provider.messages.single.id, equals('c-prefix')); }); test('duplicate incoming message increments received copy count', () { @@ -465,6 +463,30 @@ void main() { expect(display.single.occurrenceCount, equals(2)); }); + test('display list keeps a single sent message for self replay', () { + final provider = MessagesProvider(); + provider.resolveContactNameCallback = (_) => 'dz0ny (SI)'; + provider.addSentMessage( + _buildSentChannelMessage(id: 'display-sent', senderTimestamp: 1700000900), + ); + provider.markMessageSent('display-sent', 0, 0); + + provider.addMessage( + _buildReceivedChannelReplay( + id: 'display-received', + senderTimestamp: 1700000901, + senderName: 'dz0ny (SI)', + ), + ); + + final display = provider.buildDisplayMessages( + provider.getRecentMessages(), + ); + + expect(display, hasLength(1)); + expect(display.single.message.id, equals('display-sent')); + }); + test('missing ACK schedules a delayed retransmission', () { fakeAsync((async) { final provider = MessagesProvider(); diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index 9412570..226d7a5 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -356,7 +356,7 @@ void main() { expect(connectionProvider.pingCalls, 2); }); - test('refreshDueSensors refreshes self every minute', () async { + test('refreshDueSensors refreshes self every 30 seconds', () async { SharedPreferences.setMockInitialValues({}); final selfKey = Uint8List(32)..[0] = 0x66; final contactsProvider = ContactsProvider(); @@ -383,14 +383,21 @@ void main() { contactsProvider: contactsProvider, connectionProvider: connectionProvider, ); - expect(connectionProvider.pingCalls, 1); + expect(connectionProvider.pingCalls, 2); + + await provider.refreshDueSensors( + now: start.add(const Duration(seconds: 59)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); await provider.refreshDueSensors( now: start.add(const Duration(minutes: 1)), contactsProvider: contactsProvider, connectionProvider: connectionProvider, ); - expect(connectionProvider.pingCalls, 2); + expect(connectionProvider.pingCalls, 3); }); test(