Allow overriding contact name

This commit is contained in:
Janez T
2026-03-13 10:16:04 +01:00
parent 5e404944e9
commit 1e70f52069
30 changed files with 2251 additions and 381 deletions

View File

@@ -4,6 +4,7 @@ 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';
@@ -427,6 +428,46 @@ void main() {
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', () {
@@ -733,5 +774,45 @@ void main() {
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,
);
});
});
}
String publicKeyHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}

View File

@@ -131,6 +131,108 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'dup-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'dup-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001999,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('dup-1'));
});
test('keeps separate incoming messages when text differs', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'unique-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'unique-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002999,
text: 'different payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(2));
});
test('removed SAR markers stay hidden after provider restore', () async {
final provider = MessagesProvider();
final message = Message(
id: 'sar-hidden',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
);
provider.addMessage(message);
expect(
provider.sarMarkers.map((marker) => marker.id),
contains(message.id),
);
await provider.removeSarMarker(message.id);
expect(provider.sarMarkers, isEmpty);
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
expect(
restoredProvider.messages.map((stored) => stored.id),
contains(message.id),
);
expect(
restoredProvider.sarMarkers.map((marker) => marker.id),
isNot(contains(message.id)),
);
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
});
test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope(

View File

@@ -0,0 +1,92 @@
import 'dart:typed_data';
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/contact.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/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildChannel({required String name, required int channelIndex}) {
final publicKey = Uint8List(32);
publicKey[0] = 0xFF;
publicKey[1] = channelIndex;
return Contact(
publicKey: publicKey,
type: ContactType.channel,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpContactsTab(
WidgetTester tester, {
required List<Contact> channels,
}) async {
final contactsProvider = ContactsProvider();
for (final channel in channels) {
contactsProvider.addOrUpdateContact(channel);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ContactsTab()),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('private channel activity card shows delete action in sheet', (
tester,
) async {
await pumpContactsTab(
tester,
channels: [buildChannel(name: 'Ops', channelIndex: 3)],
);
expect(find.text('Ops'), findsOneWidget);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsWidgets);
expect(
find.text(
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
),
findsOneWidget,
);
});
}

View File

@@ -66,7 +66,7 @@ void main() {
),
);
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('No packets for this filter'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
@@ -115,7 +115,7 @@ void main() {
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget);
expect(find.textContaining('RESP'), findsOneWidget);
expect(find.text('Response'), findsWidgets);
expect(find.text('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
});
@@ -151,7 +151,7 @@ void main() {
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('No packets for this filter'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
@@ -173,7 +173,7 @@ void main() {
refresh.value += 1;
await tester.pump();
expect(find.text('No live traffic yet'), findsNothing);
expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget);
});
}

View File

@@ -122,6 +122,27 @@ void main() {
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
});
test('normalizes packet rate for windows longer than one minute', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs(
List.generate(
24,
(index) => _log(
timestamp: now.subtract(Duration(seconds: index * 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
),
now: now,
window: const Duration(minutes: 5),
);
expect(snapshot.totalCount, 24);
expect(snapshot.windowDuration, const Duration(minutes: 5));
expect(snapshot.packetsPerMinute, 5);
});
test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8));

View File

@@ -98,4 +98,14 @@ void main() {
DateTime.fromMillisecondsSinceEpoch(1700000100400),
);
});
test('persists removed SAR marker IDs', () async {
final storage = MessageStorageService();
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
final restored = await storage.loadRemovedSarMarkerIds();
expect(restored, equals({'sar-1', 'sar-2'}));
});
}

View File

@@ -26,12 +26,12 @@ void main() {
);
}
group('ContactGrouping.buildItems', () {
group('ContactGrouping.inferGroups', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
@@ -55,20 +55,20 @@ void main() {
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.first.group!.label, 'SI-');
expect(
items.first.group!.contacts.map((contact) => contact.displayName),
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
);
expect(items.last.contact!.displayName, 'OTHER');
expect(groups, hasLength(1));
expect(groups.first.label, 'SI-');
expect(groups.first.contacts.map((contact) => contact.displayName), [
'SI-1',
'SI-2',
'SI-3',
'SI-4',
]);
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
@@ -82,18 +82,12 @@ void main() {
),
]);
expect(items, hasLength(3));
expect(items.every((item) => !item.isGroup), isTrue);
expect(groups, isEmpty);
});
test('orders groups and ungrouped contacts by latest last seen', () {
test('orders groups by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
final groups = ContactGrouping.inferGroups([
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
@@ -110,11 +104,261 @@ void main() {
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'HR-1',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 7,
name: 'HR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 8,
name: 'HR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 9,
name: 'HR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.last.contact!.displayName, 'Lone');
expect(groups, hasLength(2));
expect(groups.first.label, 'SI-');
expect(groups.last.label, 'HR-');
});
test('collapses extra auto-groups into a shared parent prefix', () {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'HU-PE-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'HU-PE-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'HU-GA-1',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'HU-GA-2',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
buildContact(
seed: 13,
name: 'HU-PE-3',
lastSeen: now.subtract(const Duration(minutes: 12)),
),
buildContact(
seed: 14,
name: 'HU-PE-4',
lastSeen: now.subtract(const Duration(minutes: 13)),
),
buildContact(
seed: 15,
name: 'HU-GA-3',
lastSeen: now.subtract(const Duration(minutes: 14)),
),
buildContact(
seed: 16,
name: 'HU-GA-4',
lastSeen: now.subtract(const Duration(minutes: 15)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups, hasLength(3));
expect(groups[0].label, 'AL-');
expect(groups[1].label, 'BR-');
expect(groups[2].label, 'HU-');
expect(groups[2].contacts.map((contact) => contact.displayName), [
'HU-PE-1',
'HU-PE-2',
'HU-GA-1',
'HU-GA-2',
'HU-PE-3',
'HU-PE-4',
'HU-GA-3',
'HU-GA-4',
]);
expect(groups[2].matchPrefixes, ['HU-']);
});
test(
'does not create an Others bucket when overflow has no shared parent',
() {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'CR-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'CR-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'CR-3',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'CR-4',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
},
);
});
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
test('matches the inferred auto-group label for prefixed contacts', () {
final contact = buildContact(
seed: 99,
name: 'SI-1',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
isTrue,
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
isTrue,
);
});
test('returns false when a contact has no inferred auto-group label', () {
final contact = buildContact(
seed: 100,
name: 'Lone Contact',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
isFalse,
);
});
test('finds a shared inferred label for related repeater names', () {
final contacts = [
buildContact(
seed: 101,
name: 'HU-PE',
lastSeen: DateTime(2026, 3, 10, 12),
),
buildContact(
seed: 102,
name: 'HU-GA',
lastSeen: DateTime(2026, 3, 10, 11, 59),
),
];
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
});
test('finds a shared parent label for related subgroup prefixes', () {
expect(
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
'HU-',
);
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
});
});
}

View File

@@ -82,4 +82,17 @@ void main() {
expect(find.text('Trace'), findsNothing);
});
testWidgets('shows overridden contact name as primary label', (tester) async {
await pumpTile(
tester,
buildContact(
name: 'John Smith',
type: ContactType.chat,
).copyWith(nameOverride: 'Rescue One'),
);
expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing);
});
}

View File

@@ -71,4 +71,31 @@ void main() {
expect(find.text('7'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
testWidgets('can hide show all option for contact-only flows', (
tester,
) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: [contact],
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: const {},
showAllOption: false,
onSelect: (_, __) {},
),
),
),
);
expect(find.text('Show all'), findsNothing);
expect(find.text('John Smith'), findsOneWidget);
});
}