Compare commits

...

4 Commits

Author SHA1 Message Date
Janez T
6978f24440 fix: Merge self replay and refresh self telemetry 2026-03-24 19:07:44 +01:00
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
16 changed files with 328 additions and 63 deletions

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 125;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>125</string>
<string>128</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000216">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000616">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.086406">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.370543">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="96.919368">
<testcase classname="fastlane.lanes" name="2: build_app" time="105.689527">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="563.579992">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="237.850068">
</testcase>

View File

@@ -2879,8 +2879,13 @@ class AppProvider with ChangeNotifier {
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): '
'${position.latitude}, ${position.longitude} via channel $channelIdx',
'📤 [AppProvider] Fast GPS send '
'reason=$reason '
'sender=$senderKey6 '
'channel=$channelIdx '
'lat=${position.latitude} '
'lon=${position.longitude} '
'ts=${packet.timestampSeconds}',
);
try {
await connectionProvider.sendChannelData(
@@ -2888,11 +2893,32 @@ class AppProvider with ChangeNotifier {
dataType: MeshCoreConstants.dataTypeDev,
payload: packet.encodeBinary(),
);
debugPrint(
'✅ [AppProvider] Fast GPS sent '
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
);
} catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
}
}
Future<bool> sendTestFastLocationUpdate() async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
final position = await locationTrackingService.getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
);
if (position == null) {
return false;
}
await _sendFastLocationUpdate(position, reason: 'test');
return true;
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {

View File

@@ -1143,13 +1143,34 @@ class ContactsProvider with ChangeNotifier {
),
);
debugPrint(
'📥 [ContactsProvider] Fast GPS received '
'sender=${packet.senderKey6} '
'contact=${contact.displayName} '
'contactKey=${contact.publicKeyHex} '
'lat=${packet.latitude} '
'lon=${packet.longitude} '
'ts=${packet.timestampSeconds}',
);
final updatedContact = contact.copyWith(
telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds,
lastMod: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
);
_contacts[contact.publicKeyHex] = updatedContact;
_estimatedLocations[contact.publicKeyHex] = LatLng(
packet.latitude,
packet.longitude,
);
debugPrint(
'✅ [ContactsProvider] Fast GPS applied '
'contact=${updatedContact.displayName} '
'contactKey=${updatedContact.publicKeyHex} '
'lastAdvert=${updatedContact.lastAdvert}',
);
_persistContacts();
notifyListeners();
}

View File

@@ -664,6 +664,27 @@ class MessagesProvider with ChangeNotifier {
return; // Skip duplicate
}
final matchingSentReplayIndex = _findMatchingSentReplayIndex(finalMessage);
if (matchingSentReplayIndex != -1) {
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);
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
@@ -712,7 +733,8 @@ class MessagesProvider with ChangeNotifier {
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) ||
if (existing.isSentMessage ||
!_matchesDuplicateScope(existing, message) ||
existing.text != message.text) {
continue;
}
@@ -790,6 +812,27 @@ class MessagesProvider with ChangeNotifier {
existingSenderName == incomingSenderName;
}
int _findMatchingSentReplayIndex(Message message) {
if (!message.isChannelMessage || message.isSentMessage) {
return -1;
}
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!existing.isSentMessage ||
!existing.isChannelMessage ||
existing.text != message.text) {
continue;
}
if (_matchesDuplicateScope(existing, message)) {
return index;
}
}
return -1;
}
/// Add multiple messages
void addMessages(List<Message> messages) {
int addedCount = 0;
@@ -1187,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) {
@@ -1207,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;

View File

@@ -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<String>.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<void> 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);

View File

@@ -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<SensorsTab> {
static const Duration _autoRefreshTickInterval = Duration(seconds: 30);
Timer? _minuteTicker;
final Map<String, DateTime> _lastCenteredTelemetryAtBySensor =
<String, DateTime>{};
@override
void initState() {
@@ -61,22 +65,8 @@ class _SensorsTabState extends State<SensorsTab> {
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<SensorsTab> {
);
}
void _maybeCenterMapOnTelemetryUpdate(
Iterable<String> 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<MapProvider>().navigateToLocation(
location: location,
zoom: 15.0,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -247,6 +296,16 @@ class _SensorsTabState extends State<SensorsTab> {
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final displayKeys = <String>[
...?selfDisplayKey == null ? null : <String>[selfDisplayKey],
...watchedKeys,
];
_maybeCenterMapOnTelemetryUpdate(
displayKeys,
sensorsProvider: sensorsProvider,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final hasAnyCards =
selfDisplayKey != null || watchedKeys.isNotEmpty;

View File

@@ -341,7 +341,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Meters',
helperText: 'Valid range: 1 to 1000 meters',
helperText: 'Valid range: 10 to 1000 meters',
),
),
actions: [
@@ -353,7 +353,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
final parsed = double.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(1.0, 1000.0));
Navigator.pop(context, parsed.clamp(10.0, 1000.0));
},
child: const Text('Save'),
),
@@ -382,7 +382,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Seconds',
helperText: 'Valid range: 5 to 60 seconds',
helperText: 'Valid range: 10 to 31 seconds',
),
),
actions: [
@@ -394,7 +394,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(5, 60));
Navigator.pop(context, parsed.clamp(10, 31));
},
child: const Text('Save'),
),
@@ -426,6 +426,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
return 'Channel $channelIdx unavailable';
}
Future<void> _sendTestFastLocationUpdate() async {
final appProvider = context.read<AppProvider>();
final sent = await appProvider.sendTestFastLocationUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
sent
? 'Test fast GPS update sent.'
: 'Unable to send test fast GPS update.',
),
backgroundColor: sent ? Colors.green : Colors.orange,
),
);
}
Future<void> _editFastLocationChannel() async {
final channels =
List<Contact>.from(context.read<ContactsProvider>().channels)
@@ -1854,6 +1871,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationChannel,
),
ListTile(
leading: const Icon(Icons.send),
title: const Text('Test send update'),
subtitle: const Text(
'Send one fast GPS update immediately to the configured channel.',
),
trailing: const Icon(Icons.chevron_right),
onTap: _sendTestFastLocationUpdate,
),
ListTile(
leading: Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),

View File

@@ -144,11 +144,11 @@ class AppConfigSnapshotService {
}
if (section.fastLocationMovementThresholdMeters != null) {
locationTracking.fastLocationMovementThresholdMeters =
section.fastLocationMovementThresholdMeters!;
section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
}
if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!;
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
}
await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings();

View File

@@ -20,6 +20,14 @@ import 'profiles_feature_service.dart';
/// - MeshCore mesh network integration
/// - Real-time position updates via callbacks
class LocationTrackingService {
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
static const double _minFastLocationMovementThresholdMeters = 10.0;
static const int _defaultFastLocationActiveCadenceSeconds = 10;
static const int _minFastLocationActiveCadenceSeconds = 10;
static const int _maxFastLocationActiveCadenceSeconds = 31;
static const Duration _fastLocationMinimumUpdateInterval = Duration(
seconds: 31,
);
// ============================================================================
// Singleton Pattern
// ============================================================================
@@ -75,10 +83,12 @@ class LocationTrackingService {
bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0;
double fastLocationMovementThresholdMeters =
_defaultFastLocationMovementThresholdMeters;
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
int fastLocationActiveCadenceSeconds =
_defaultFastLocationActiveCadenceSeconds;
/// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx;
@@ -522,12 +532,18 @@ class LocationTrackingService {
}
Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
fastLocationMovementThresholdMeters = meters.clamp(
_minFastLocationMovementThresholdMeters,
1000.0,
);
await saveSettings();
}
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
fastLocationActiveCadenceSeconds = seconds.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
await saveSettings();
_refreshFastLocationTimer();
}
@@ -583,6 +599,11 @@ class LocationTrackingService {
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt;
if (previousTime != null &&
now.difference(previousTime) < _fastLocationMinimumUpdateInterval) {
return;
}
if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween(
previous.latitude,
@@ -673,12 +694,14 @@ class LocationTrackingService {
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters =
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance)
.clamp(1.0, 1000.0);
_defaultFastLocationMovementThresholdMeters)
.clamp(_minFastLocationMovementThresholdMeters, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp(
5,
60,
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
_defaultFastLocationActiveCadenceSeconds)
.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));

View File

@@ -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.0322.2+39
version: 2026.0324.1+43
environment:
sdk: ^3.9.2

View File

@@ -781,6 +781,19 @@ void main() {
expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234));
expect(updated.lastMod, equals(1700001234));
expect(
provider.estimatedLocationFor(updated.publicKeyHex),
isNotNull,
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.latitude,
closeTo(44.123456, 0.000001),
);
expect(
provider.estimatedLocationFor(updated.publicKeyHex)!.longitude,
closeTo(13.654321, 0.000001),
);
});
test('ignores unknown sender prefix safely', () {

View File

@@ -214,7 +214,7 @@ void main() {
});
});
test('channel warning clears when replay is deduped into sent bubble', () {
test('channel warning clears when replay arrives after send', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
@@ -240,10 +240,11 @@ void main() {
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-warn-replay'));
});
});
test('channel replay is deduped 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(
@@ -261,7 +262,7 @@ void main() {
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-echo'));
expect(provider.messages.single.senderName, equals('dz0ny (SI)'));
expect(provider.messages.single.pathLen, equals(1));
});
test(
@@ -308,7 +309,7 @@ void main() {
expect(provider.messages, hasLength(2));
});
test('channel replay can dedupe 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),
@@ -328,7 +329,7 @@ void main() {
expect(provider.messages.single.id, equals('c-lazy'));
});
test('channel replay dedupes 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(
@@ -462,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();

View File

@@ -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(

View File

@@ -19,6 +19,13 @@ void main() {
service.fastLocationChannelIdx = null;
});
test('loads conservative fast location defaults', () async {
await service.loadSettings();
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 10);
});
test('persists and restores fast location channel idx', () async {
await service.updateFastLocationChannelIdx(3);
@@ -37,4 +44,12 @@ void main() {
expect(service.fastLocationChannelIdx, isNull);
});
test('clamps fast location settings to conservative limits', () async {
await service.updateFastLocationMovementThreshold(3);
await service.updateFastLocationActiveCadenceSeconds(45);
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 31);
});
}