mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Switch the fast-GPS beacon to the firmware's canonical 16-byte wire format (magic, key6, lat/lon microdegrees, speed km/h) — no on-wire timestamp; the receiver stamps its own RX time. Drops compatibility with the older 19-byte app format. Mirror the firmware's adaptive send cadence in the app-fallback path (LocationTrackingService): anchor + dwell motion hysteresis, EMA ground speed, 9-min stationary keepalive, fix-quality (accuracy) gate, and a post-RX hold-off to avoid channel collisions. Parked nodes now beacon the stable anchor instead of GPS wander. Add the fast_gps_region config: region-scope picker in settings backed by the firmware's fast_gps_region / fast_gps_regions custom vars. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1093 lines
36 KiB
Dart
1093 lines
36 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:meshcore_sar_app/models/contact.dart';
|
|
import 'package:meshcore_sar_app/models/contact_group.dart';
|
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
|
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
|
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
Uint8List createPublicKey(int seed) {
|
|
return Uint8List.fromList(List<int>.generate(32, (index) => seed + index));
|
|
}
|
|
|
|
Contact createContact({
|
|
required Uint8List key,
|
|
required ContactType type,
|
|
String? name,
|
|
}) {
|
|
return Contact(
|
|
publicKey: key,
|
|
type: type,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: name ?? 'Test Contact',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: (46.0569 * 1e6).toInt(),
|
|
advLon: (14.5058 * 1e6).toInt(),
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
);
|
|
}
|
|
|
|
group('ContactsProvider.updateTelemetry', () {
|
|
late ContactsProvider provider;
|
|
late Uint8List publicKey;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
publicKey = Uint8List.fromList([
|
|
0xAA,
|
|
0xBB,
|
|
0xCC,
|
|
0xDD,
|
|
0xEE,
|
|
0xFF,
|
|
0x01,
|
|
0x02,
|
|
0x03,
|
|
0x04,
|
|
0x05,
|
|
0x06,
|
|
0x07,
|
|
0x08,
|
|
0x09,
|
|
0x0A,
|
|
0x0B,
|
|
0x0C,
|
|
0x0D,
|
|
0x0E,
|
|
0x0F,
|
|
0x10,
|
|
0x11,
|
|
0x12,
|
|
0x13,
|
|
0x14,
|
|
0x15,
|
|
0x16,
|
|
0x17,
|
|
0x18,
|
|
0x19,
|
|
0x1A,
|
|
]);
|
|
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: publicKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: 'Test Contact',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: (46.0569 * 1e6).toInt(),
|
|
advLon: (14.5058 * 1e6).toInt(),
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
});
|
|
|
|
test('ignores telemetry GPS at 0,0 and keeps advert location', () {
|
|
final lppData = CayenneLppParser.createGpsData(
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
);
|
|
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), lppData);
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNull);
|
|
expect(updated.displayLocation, isNotNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(46.0569, 0.000001));
|
|
expect(updated.displayLocation!.longitude, closeTo(14.5058, 0.000001));
|
|
});
|
|
|
|
test('keeps valid telemetry GPS and uses it for display location', () {
|
|
final lppData = CayenneLppParser.createGpsData(
|
|
latitude: 45.0001,
|
|
longitude: 13.9999,
|
|
);
|
|
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), lppData);
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.latitude,
|
|
closeTo(45.0001, 0.0001),
|
|
);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.longitude,
|
|
closeTo(13.9999, 0.0001),
|
|
);
|
|
expect(updated.displayLocation, isNotNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001));
|
|
expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001));
|
|
expect(updated.advLat, equals((45.0001 * 1e6).round()));
|
|
expect(updated.advLon, equals((13.9999 * 1e6).round()));
|
|
});
|
|
|
|
test(
|
|
'clears telemetry gps on refresh when gps is invalid or missing but keeps fallback display location',
|
|
() {
|
|
final contactTypes = <ContactType>[
|
|
ContactType.chat,
|
|
ContactType.repeater,
|
|
ContactType.sensor,
|
|
ContactType.room,
|
|
ContactType.channel,
|
|
];
|
|
|
|
for (var i = 0; i < contactTypes.length; i++) {
|
|
final scopedProvider = ContactsProvider();
|
|
final scopedKey = createPublicKey(16 + i);
|
|
final contactType = contactTypes[i];
|
|
scopedProvider.addOrUpdateContact(
|
|
createContact(
|
|
key: scopedKey,
|
|
type: contactType,
|
|
name: 'Contact ${contactType.name}',
|
|
),
|
|
);
|
|
|
|
final validGps = CayenneLppParser.createGpsData(
|
|
latitude: 45.1234,
|
|
longitude: 13.8765,
|
|
);
|
|
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps);
|
|
|
|
// No GPS frame should clear telemetry GPS but keep the last known
|
|
// contact location as display fallback.
|
|
final batteryOnly = CayenneLppParser.createBatteryData(3.8);
|
|
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly);
|
|
|
|
var updated = scopedProvider.findContactByKey(scopedKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
|
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
|
|
|
|
// Invalid 0,0 GPS frame should behave the same way.
|
|
final invalidGps = CayenneLppParser.createGpsData(
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
);
|
|
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), invalidGps);
|
|
|
|
updated = scopedProvider.findContactByKey(scopedKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
|
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'retains last known gps when a contact refresh arrives without location',
|
|
() {
|
|
final firstFix = CayenneLppParser.createGpsData(
|
|
latitude: 45.1234,
|
|
longitude: 13.8765,
|
|
);
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), firstFix);
|
|
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: publicKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: 'Test Contact',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: 0,
|
|
advLon: 0,
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.latitude,
|
|
closeTo(45.1234, 0.0001),
|
|
);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.longitude,
|
|
closeTo(13.8765, 0.0001),
|
|
);
|
|
expect(updated.advLat, equals((45.1234 * 1e6).round()));
|
|
expect(updated.advLon, equals((13.8765 * 1e6).round()));
|
|
expect(updated.displayLocation, isNotNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
|
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
|
|
},
|
|
);
|
|
|
|
test('retains existing telemetry when contact refresh omits telemetry', () {
|
|
final initialTelemetry = ContactTelemetry(
|
|
gpsLocation: const LatLng(45.1234, 13.8765),
|
|
batteryPercentage: 76.5,
|
|
batteryMilliVolts: 3890,
|
|
temperature: 21.5,
|
|
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
|
|
humidity: 62.0,
|
|
pressure: 1008.4,
|
|
extraSensorData: const {'co2': 415.0},
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: publicKey,
|
|
type: ContactType.chat,
|
|
).copyWith(telemetry: initialTelemetry),
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: publicKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: 'Test Contact Refreshed',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: 0,
|
|
advLon: 0,
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
|
|
expect(updated.telemetry!.batteryPercentage, equals(76.5));
|
|
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
|
|
expect(updated.telemetry!.temperature, equals(21.5));
|
|
expect(updated.telemetry!.humidity, equals(62.0));
|
|
expect(updated.telemetry!.pressure, equals(1008.4));
|
|
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
|
});
|
|
|
|
test('retains existing telemetry during bulk contacts sync', () {
|
|
final initialTelemetry = ContactTelemetry(
|
|
gpsLocation: const LatLng(45.1234, 13.8765),
|
|
batteryPercentage: 76.5,
|
|
batteryMilliVolts: 3890,
|
|
temperature: 21.5,
|
|
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
|
|
humidity: 62.0,
|
|
pressure: 1008.4,
|
|
extraSensorData: const {'co2': 415.0},
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: publicKey,
|
|
type: ContactType.chat,
|
|
).copyWith(telemetry: initialTelemetry),
|
|
);
|
|
|
|
provider.addContacts([
|
|
Contact(
|
|
publicKey: publicKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: 'Synced Contact',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: 0,
|
|
advLon: 0,
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
]);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
|
|
expect(updated.telemetry!.batteryPercentage, equals(76.5));
|
|
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
|
|
expect(updated.telemetry!.temperature, equals(21.5));
|
|
expect(updated.telemetry!.humidity, equals(62.0));
|
|
expect(updated.telemetry!.pressure, equals(1008.4));
|
|
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
|
});
|
|
|
|
test(
|
|
'retains scalar telemetry but wipes stale extra sensor fields on refresh',
|
|
() {
|
|
final fullTelemetry = ContactTelemetry(
|
|
gpsLocation: const LatLng(46.0569, 14.5058),
|
|
batteryPercentage: 54.0,
|
|
batteryMilliVolts: 3780,
|
|
temperature: 19.5,
|
|
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
|
|
humidity: 58.0,
|
|
pressure: 1011.2,
|
|
extraSensorData: const {'pm25': 8.0},
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: publicKey,
|
|
type: ContactType.chat,
|
|
).copyWith(telemetry: fullTelemetry),
|
|
);
|
|
|
|
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNull);
|
|
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
|
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
|
expect(updated.telemetry!.batteryPercentage, isNotNull);
|
|
expect(updated.telemetry!.temperature, equals(19.5));
|
|
expect(updated.telemetry!.humidity, equals(58.0));
|
|
expect(updated.telemetry!.pressure, equals(1011.2));
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
containsPair('__source_channel:battery', 0),
|
|
);
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
containsPair('__source_channel:voltage', 0),
|
|
);
|
|
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'replaces old source-channel mappings when a metric moves channels',
|
|
() {
|
|
final initialTelemetry = ContactTelemetry(
|
|
gpsLocation: null,
|
|
batteryPercentage: null,
|
|
batteryMilliVolts: null,
|
|
temperature: 21.5,
|
|
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
|
|
humidity: null,
|
|
pressure: null,
|
|
extraSensorData: const {
|
|
'__source_channel:temperature': 2,
|
|
'temperature_2': 21.5,
|
|
'humidity_4': 66.0,
|
|
},
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: publicKey,
|
|
type: ContactType.chat,
|
|
).copyWith(telemetry: initialTelemetry),
|
|
);
|
|
|
|
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
|
|
23.5,
|
|
channel: 3,
|
|
);
|
|
|
|
provider.updateTelemetry(
|
|
publicKey.sublist(0, 6),
|
|
movedChannelTelemetry,
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
containsPair('__source_channel:temperature', 3),
|
|
);
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
containsPair('temperature_3', closeTo(23.5, 0.1)),
|
|
);
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
isNot(contains('temperature_2')),
|
|
);
|
|
expect(
|
|
updated.telemetry!.extraSensorData,
|
|
isNot(contains('humidity_4')),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('builds message snapshot from latest valid telemetry', () {
|
|
final telemetryData = CayenneLppParser.createGpsData(
|
|
latitude: 45.0001,
|
|
longitude: 13.9999,
|
|
);
|
|
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
|
|
final contact = provider.findContactByKey(publicKey)!;
|
|
final snapshot = provider.buildMessageContactLocationSnapshot(
|
|
contact,
|
|
capturedAt: DateTime.now(),
|
|
);
|
|
|
|
expect(snapshot, isNotNull);
|
|
expect(snapshot!.source, equals('telemetry'));
|
|
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
|
|
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
|
|
});
|
|
|
|
test('builds message snapshot from advert when telemetry is invalid', () {
|
|
final invalidTelemetry = CayenneLppParser.createGpsData(
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
);
|
|
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
|
|
final contact = provider.findContactByKey(publicKey)!;
|
|
final snapshot = provider.buildMessageContactLocationSnapshot(
|
|
contact,
|
|
capturedAt: DateTime.now(),
|
|
);
|
|
|
|
expect(snapshot, isNotNull);
|
|
expect(snapshot!.source, equals('advert'));
|
|
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
|
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
|
});
|
|
|
|
test(
|
|
'persists last valid telemetry gps on the contact across reloads',
|
|
() async {
|
|
final telemetryData = CayenneLppParser.createGpsData(
|
|
latitude: 45.0001,
|
|
longitude: 13.9999,
|
|
);
|
|
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final reloadedProvider = ContactsProvider();
|
|
await reloadedProvider.initializeEarly();
|
|
|
|
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
|
|
expect(reloaded.advLat, equals((45.0001 * 1e6).round()));
|
|
expect(reloaded.advLon, equals((13.9999 * 1e6).round()));
|
|
expect(reloaded.advertLocation, isNotNull);
|
|
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
|
|
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
|
|
},
|
|
);
|
|
|
|
test('prefers a local name override and preserves it across refreshes', () {
|
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
|
|
|
final renamed = provider.findContactByKey(publicKey)!;
|
|
expect(renamed.nameOverride, 'Rescue One');
|
|
expect(renamed.displayName, 'Rescue One');
|
|
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: publicKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: 0,
|
|
outPath: Uint8List(64),
|
|
advName: 'Updated Advertised Name',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: (46.0569 * 1e6).toInt(),
|
|
advLon: (14.5058 * 1e6).toInt(),
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
|
|
final refreshed = provider.findContactByKey(publicKey)!;
|
|
expect(refreshed.nameOverride, 'Rescue One');
|
|
expect(refreshed.displayName, 'Rescue One');
|
|
expect(refreshed.advName, 'Updated Advertised Name');
|
|
});
|
|
|
|
test('persists a local name override across reloads', () async {
|
|
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
final reloadedProvider = ContactsProvider();
|
|
await reloadedProvider.initializeEarly();
|
|
|
|
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
|
|
expect(reloaded.nameOverride, 'Rescue One');
|
|
expect(reloaded.displayName, 'Rescue One');
|
|
});
|
|
});
|
|
|
|
group('ContactsProvider route updates', () {
|
|
late ContactsProvider provider;
|
|
late Uint8List publicKey;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
publicKey = createPublicKey(64);
|
|
provider.addOrUpdateContact(
|
|
createContact(key: publicKey, type: ContactType.chat, name: 'Routey'),
|
|
);
|
|
});
|
|
|
|
test('optimistically stores a multi-byte route locally', () {
|
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
|
|
|
provider.setContactRouteLocal(
|
|
publicKey,
|
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
|
paddedPathBytes: route.paddedPathBytes,
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.routeHasPath, isTrue);
|
|
expect(updated.routeHashSize, 2);
|
|
expect(updated.routeHopCount, 2);
|
|
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
|
});
|
|
|
|
test('stores inferred fallback gps when route is set locally', () {
|
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
|
const fallback = LatLng(46.1001, 14.5002);
|
|
|
|
provider.setContactRouteLocal(
|
|
publicKey,
|
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
|
paddedPathBytes: route.paddedPathBytes,
|
|
inferredFallbackLocation: fallback,
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.displayLocation, isNotNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
|
|
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
|
|
expect(updated.advertHistory, isNotEmpty);
|
|
});
|
|
|
|
test('resetContactRouteLocal clears route state', () {
|
|
final route = ContactRouteCodec.parse('AA,BB,CC');
|
|
provider.setContactRouteLocal(
|
|
publicKey,
|
|
signedEncodedPathLen: route.signedEncodedPathLen,
|
|
paddedPathBytes: route.paddedPathBytes,
|
|
);
|
|
|
|
provider.resetContactRouteLocal(publicKey);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.routeHasPath, isFalse);
|
|
expect(updated.routeSummary, 'Flood/Unknown');
|
|
});
|
|
|
|
test('retains existing route when contact refresh omits path', () {
|
|
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
|
|
provider.setContactRouteLocal(
|
|
publicKey,
|
|
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
|
|
paddedPathBytes: retainedRoute.paddedPathBytes,
|
|
);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: publicKey,
|
|
type: ContactType.chat,
|
|
name: 'Routey',
|
|
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.routeHasPath, isTrue);
|
|
expect(updated.routeCanonicalText, 'AABB,CCDD');
|
|
});
|
|
|
|
test('applies retained pending advert route when contact is resolved', () {
|
|
final pendingKey = createPublicKey(96);
|
|
final retainedRoute = ContactRouteCodec.parse('1122,3344');
|
|
|
|
provider.retainReceivedRoute(
|
|
pendingKey,
|
|
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
|
|
paddedPathBytes: retainedRoute.paddedPathBytes,
|
|
);
|
|
provider.addPendingAdvert(pendingKey);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: pendingKey,
|
|
type: ContactType.chat,
|
|
name: 'Pending Routey',
|
|
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(pendingKey)!;
|
|
expect(updated.routeHasPath, isTrue);
|
|
expect(updated.routeCanonicalText, '1122,3344');
|
|
expect(
|
|
provider.pendingAdverts.where(
|
|
(advert) => advert.publicKeyHex == updated.publicKeyHex,
|
|
),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('infers a fallback location 100m from last-hop repeater', () {
|
|
final repeaterKey = Uint8List.fromList([
|
|
0xCC,
|
|
0xDD,
|
|
0x10,
|
|
0x11,
|
|
0x12,
|
|
0x13,
|
|
...List<int>.generate(26, (index) => index + 20),
|
|
]);
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: repeaterKey,
|
|
type: ContactType.repeater,
|
|
name: 'Relay Alpha',
|
|
),
|
|
);
|
|
|
|
final targetKey = createPublicKey(120);
|
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: targetKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: route.signedEncodedPathLen,
|
|
outPath: route.paddedPathBytes,
|
|
advName: 'No GPS',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: 0,
|
|
advLon: 0,
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(targetKey)!;
|
|
final inferred = updated.displayLocation;
|
|
final repeater = provider.findContactByKey(repeaterKey)!;
|
|
final repeaterLocation = repeater.displayLocation;
|
|
|
|
expect(inferred, isNotNull);
|
|
expect(repeaterLocation, isNotNull);
|
|
|
|
final distanceMeters = Geolocator.distanceBetween(
|
|
repeaterLocation!.latitude,
|
|
repeaterLocation.longitude,
|
|
inferred!.latitude,
|
|
inferred.longitude,
|
|
);
|
|
expect(distanceMeters, closeTo(100.0, 8.0));
|
|
});
|
|
|
|
test(
|
|
'does not infer a fallback location when the contact advertises one',
|
|
() {
|
|
final repeaterKey = Uint8List.fromList([
|
|
0xCC,
|
|
0xDD,
|
|
0x10,
|
|
0x11,
|
|
0x12,
|
|
0x13,
|
|
...List<int>.generate(26, (index) => index + 20),
|
|
]);
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: repeaterKey,
|
|
type: ContactType.repeater,
|
|
name: 'Relay Alpha',
|
|
),
|
|
);
|
|
|
|
final targetKey = createPublicKey(121);
|
|
final route = ContactRouteCodec.parse('AABB,CCDD');
|
|
provider.addOrUpdateContact(
|
|
Contact(
|
|
publicKey: targetKey,
|
|
type: ContactType.chat,
|
|
flags: 0,
|
|
outPathLen: route.signedEncodedPathLen,
|
|
outPath: route.paddedPathBytes,
|
|
advName: 'Has Advert',
|
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
advLat: (45.1234 * 1e6).toInt(),
|
|
advLon: (13.8765 * 1e6).toInt(),
|
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
),
|
|
);
|
|
|
|
final updated = provider.findContactByKey(targetKey)!;
|
|
expect(updated.displayLocation, isNotNull);
|
|
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
|
|
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
|
|
},
|
|
);
|
|
});
|
|
|
|
group('ContactsProvider.updateFastGps', () {
|
|
late ContactsProvider provider;
|
|
late Uint8List publicKey;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
publicKey = createPublicKey(50);
|
|
provider.addOrUpdateContact(
|
|
createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'),
|
|
);
|
|
});
|
|
|
|
test('updates gps while preserving other telemetry fields', () {
|
|
final batteryOnly = CayenneLppParser.createBatteryData(3.9);
|
|
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
|
|
|
|
provider.updateFastGps(
|
|
publicKey.sublist(0, 6),
|
|
const FastGpsPacket(
|
|
senderKey6: '323334353637',
|
|
latitude: 44.123456,
|
|
longitude: 13.654321,
|
|
speedKmh: 12,
|
|
),
|
|
);
|
|
|
|
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final updated = provider.findContactByKey(publicKey)!;
|
|
expect(updated.telemetry, isNotNull);
|
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.latitude,
|
|
closeTo(44.123456, 0.000001),
|
|
);
|
|
expect(
|
|
updated.telemetry!.gpsLocation!.longitude,
|
|
closeTo(13.654321, 0.000001),
|
|
);
|
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
|
expect(updated.advLat, equals((44.123456 * 1e6).round()));
|
|
expect(updated.advLon, equals((13.654321 * 1e6).round()));
|
|
// The beacon carries no timestamp; the receiver stamps its own RX time.
|
|
expect(updated.lastAdvert, closeTo(nowSeconds, 5));
|
|
expect(updated.lastMod, closeTo(nowSeconds, 5));
|
|
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', () {
|
|
final before = provider.findContactByKey(publicKey)!;
|
|
provider.updateFastGps(
|
|
Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
|
const FastGpsPacket(
|
|
senderKey6: '010203040506',
|
|
latitude: 10,
|
|
longitude: 20,
|
|
speedKmh: 0,
|
|
),
|
|
);
|
|
final after = provider.findContactByKey(publicKey)!;
|
|
expect(after.advLat, equals(before.advLat));
|
|
expect(after.advLon, equals(before.advLon));
|
|
});
|
|
});
|
|
|
|
group('ContactsProvider saved contact groups', () {
|
|
late ContactsProvider provider;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
});
|
|
|
|
test('adds and removes saved groups by filter', () async {
|
|
expect(provider.savedContactGroups, isEmpty);
|
|
|
|
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
|
|
|
|
expect(provider.savedContactGroups, hasLength(1));
|
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
|
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
|
|
|
|
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
|
|
|
|
expect(provider.savedContactGroups, isEmpty);
|
|
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
|
|
});
|
|
|
|
test('loads persisted saved groups during initialization', () async {
|
|
await provider.addSavedGroupForFilter('rooms', 'ops');
|
|
|
|
final restored = ContactsProvider();
|
|
await restored.initializeEarly();
|
|
|
|
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
|
|
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
|
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
|
|
});
|
|
|
|
test('replaces persisted auto groups for a section', () async {
|
|
await provider.replaceAutoGroupsForSection('repeaters', [
|
|
SavedContactGroup(
|
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al',
|
|
sectionKey: 'repeaters',
|
|
label: 'AL-',
|
|
query: 'AL-',
|
|
createdAt: DateTime(2026, 3, 10, 12),
|
|
matchPrefixes: const ['AL-'],
|
|
isAutoGroup: true,
|
|
),
|
|
SavedContactGroup(
|
|
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others',
|
|
sectionKey: 'repeaters',
|
|
label: 'Others',
|
|
query: 'Others',
|
|
createdAt: DateTime(2026, 3, 10, 12),
|
|
matchPrefixes: const ['CR-', 'DE-'],
|
|
isAutoGroup: true,
|
|
),
|
|
]);
|
|
|
|
final restored = ContactsProvider();
|
|
await restored.initializeEarly();
|
|
|
|
expect(restored.savedGroupsForSection('repeaters'), hasLength(2));
|
|
expect(
|
|
restored.savedGroupsForSection('repeaters').first.matchPrefixes,
|
|
isNotEmpty,
|
|
);
|
|
expect(
|
|
restored.savedGroupsForSection('repeaters').first.isAutoGroup,
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('ContactsProvider device sync preparation', () {
|
|
late ContactsProvider provider;
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
});
|
|
|
|
test(
|
|
'keeps runtime contacts visible during sync without erasing persisted contacts or saved groups',
|
|
() async {
|
|
final key = createPublicKey(140);
|
|
final pendingKey = createPublicKey(180);
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(key: key, type: ContactType.chat, name: 'Synced Later'),
|
|
);
|
|
provider.addPendingAdvert(pendingKey);
|
|
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
|
|
|
|
await provider.prepareForDeviceContactSync();
|
|
|
|
expect(
|
|
provider.chatContacts.map((contact) => contact.advName),
|
|
contains('Synced Later'),
|
|
);
|
|
expect(provider.pendingAdverts, hasLength(1));
|
|
expect(provider.savedGroupsForSection('teamMembers'), hasLength(1));
|
|
|
|
final restored = ContactsProvider();
|
|
await restored.initializeEarly();
|
|
|
|
expect(
|
|
restored.chatContacts.map((contact) => contact.advName),
|
|
contains('Synced Later'),
|
|
);
|
|
expect(restored.savedGroupsForSection('teamMembers'), hasLength(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'retains cached telemetry when sync re-adds a contact without telemetry',
|
|
() async {
|
|
final key = createPublicKey(141);
|
|
final contact = createContact(
|
|
key: key,
|
|
type: ContactType.sensor,
|
|
name: 'Sparse Sync Sensor',
|
|
).copyWith(
|
|
telemetry: ContactTelemetry(
|
|
temperature: 18.5,
|
|
humidity: 64.0,
|
|
timestamp: DateTime(2026, 3, 22, 8, 30),
|
|
),
|
|
);
|
|
|
|
provider.addOrUpdateContact(contact);
|
|
await provider.prepareForDeviceContactSync();
|
|
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: key,
|
|
type: ContactType.sensor,
|
|
name: 'Sparse Sync Sensor',
|
|
),
|
|
);
|
|
|
|
final restored = provider.findContactByKey(key);
|
|
expect(restored, isNotNull);
|
|
expect(restored!.telemetry, isNotNull);
|
|
expect(restored.telemetry!.temperature, closeTo(18.5, 0.01));
|
|
expect(restored.telemetry!.humidity, closeTo(64.0, 0.01));
|
|
},
|
|
);
|
|
|
|
test('keeps self telemetry cached across reconnect for the same device', () async {
|
|
final selfKey = createPublicKey(200);
|
|
|
|
await provider.initialize(devicePublicKey: selfKey);
|
|
provider.updateTelemetry(
|
|
selfKey.sublist(0, 6),
|
|
CayenneLppParser.createTemperatureData(23.5, channel: 1),
|
|
);
|
|
|
|
await provider.prepareForDeviceContactSync(devicePublicKey: selfKey);
|
|
|
|
expect(provider.selfTelemetry, isNotNull);
|
|
expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1));
|
|
});
|
|
});
|
|
|
|
group('ContactsProvider self telemetry', () {
|
|
test(
|
|
'stores self telemetry without re-adding the device as a contact',
|
|
() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final provider = ContactsProvider();
|
|
final selfKey = createPublicKey(200);
|
|
|
|
await provider.initialize(devicePublicKey: selfKey);
|
|
provider.updateTelemetry(
|
|
selfKey.sublist(0, 6),
|
|
CayenneLppParser.createTemperatureData(23.5, channel: 1),
|
|
);
|
|
|
|
expect(provider.selfTelemetry, isNotNull);
|
|
expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1));
|
|
expect(provider.findContactByKey(selfKey), isNull);
|
|
expect(
|
|
provider.contacts.any(
|
|
(contact) => contact.publicKeyHex == publicKeyHex(selfKey),
|
|
),
|
|
isFalse,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('ContactsProvider device-authoritative sync', () {
|
|
late ContactsProvider provider;
|
|
late Contact keptContact;
|
|
late Contact staleContact;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
provider = ContactsProvider();
|
|
keptContact = createContact(
|
|
key: createPublicKey(10),
|
|
type: ContactType.chat,
|
|
name: 'Still On Device',
|
|
);
|
|
staleContact = createContact(
|
|
key: createPublicKey(40),
|
|
type: ContactType.chat,
|
|
name: 'Deleted On Device',
|
|
);
|
|
provider.addOrUpdateContact(keptContact);
|
|
provider.addOrUpdateContact(staleContact);
|
|
await provider.prepareForDeviceContactSync();
|
|
});
|
|
|
|
test('removes contacts the device no longer reports after a full sync', () {
|
|
// Device only re-sends one of the two contacts during sync.
|
|
provider.addOrUpdateContact(keptContact);
|
|
|
|
provider.finalizeDeviceContactSync(deviceListComplete: true);
|
|
|
|
expect(provider.findContactByKey(keptContact.publicKey), isNotNull);
|
|
expect(provider.findContactByKey(staleContact.publicKey), isNull);
|
|
});
|
|
|
|
test('keeps all contacts when the sync was partial', () {
|
|
provider.addOrUpdateContact(keptContact);
|
|
|
|
provider.finalizeDeviceContactSync(deviceListComplete: false);
|
|
|
|
expect(provider.findContactByKey(keptContact.publicKey), isNotNull);
|
|
expect(provider.findContactByKey(staleContact.publicKey), isNotNull);
|
|
});
|
|
|
|
test('never removes channel pseudo-contacts during contact sync', () {
|
|
final channelKey = Uint8List(32)
|
|
..[0] = 0xFF
|
|
..[1] = 3;
|
|
provider.addOrUpdateContact(
|
|
createContact(
|
|
key: channelKey,
|
|
type: ContactType.channel,
|
|
name: 'Team Channel',
|
|
),
|
|
);
|
|
// Re-prepare so the channel pseudo-contact is part of the snapshot.
|
|
provider.prepareForDeviceContactSync();
|
|
provider.addOrUpdateContact(keptContact);
|
|
|
|
provider.finalizeDeviceContactSync(deviceListComplete: true);
|
|
|
|
expect(provider.findContactByKey(channelKey), isNotNull);
|
|
expect(provider.findContactByKey(staleContact.publicKey), isNull);
|
|
});
|
|
});
|
|
|
|
test('stores raw telemetry hex in metadata for verification', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final provider = ContactsProvider();
|
|
final telemetryKey = createPublicKey(150);
|
|
final contact = createContact(
|
|
key: telemetryKey,
|
|
type: ContactType.chat,
|
|
name: 'Telemetry Node',
|
|
);
|
|
provider.addOrUpdateContact(contact);
|
|
|
|
final payload = Uint8List.fromList(<int>[0x01, 0x67, 0x00, 0xEB]);
|
|
provider.updateTelemetry(telemetryKey.sublist(0, 6), payload);
|
|
|
|
final updated = provider.findContactByKey(telemetryKey)!;
|
|
expect(updated.telemetry?.extraSensorData?['__raw_lpp_hex'], '01 67 00 eb');
|
|
});
|
|
}
|
|
|
|
String publicKeyHex(Uint8List publicKey) {
|
|
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
|
}
|