diff --git a/lib/models/custom_map_config.dart b/lib/models/custom_map_config.dart index 5a479bd..57782a0 100644 --- a/lib/models/custom_map_config.dart +++ b/lib/models/custom_map_config.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -26,22 +28,34 @@ class CustomMapConfig { bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0; - LatLngBounds get bounds => LatLngBounds( - const LatLng(0, 0), - LatLng(imageHeight.toDouble(), imageWidth.toDouble()), - ); + double get _displayScale { + final heightScale = imageHeight / 90.0; + final widthScale = imageWidth / 180.0; + return math.max(1.0, math.max(heightScale, widthScale)); + } - LatLngBounds get displayBounds => LatLngBounds( - LatLng(imageHeight.toDouble(), 0), - LatLng(0, imageWidth.toDouble()), - ); + double get _displayHeight => imageHeight / _displayScale; + + double get _displayWidth => imageWidth / _displayScale; + + LatLngBounds get bounds => + LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth)); + + LatLngBounds get displayBounds => + LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth)); LatLng toDisplayPoint(LatLng point) { - return LatLng(imageHeight.toDouble() - point.latitude, point.longitude); + return LatLng( + _displayHeight - (point.latitude / _displayScale), + point.longitude / _displayScale, + ); } LatLng fromDisplayPoint(LatLng point) { - return LatLng(imageHeight.toDouble() - point.latitude, point.longitude); + return LatLng( + (_displayHeight - point.latitude) * _displayScale, + point.longitude * _displayScale, + ); } Map toJson() { diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index e9a6478..f53603f 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -735,7 +735,10 @@ class MessagesProvider with ChangeNotifier { } if (message.isContactMessage) { - return existing.senderKeyShort == message.senderKeyShort; + // Match by sender key + sender timestamp (matches official app's DB + // uniqueness: contactPublicKey + senderTimestamp + text + txtType). + return existing.senderKeyShort == message.senderKeyShort && + existing.senderTimestamp == message.senderTimestamp; } if (message.isChannelMessage) { diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 4817d6d..3ceafc9 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -169,8 +169,13 @@ class _DiscoveryScreenState extends State { }); try { + final contactsProvider = context.read(); for (final advert in adverts) { if (!mounted) break; + // Skip already-resolved contacts + if (contactsProvider.findContactByKey(advert.publicKey) != null) { + continue; + } await _resolveAdvert(advert); } } finally { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ca0f821..53c4bf7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -980,7 +980,7 @@ class _SettingsScreenState extends State { builder: (context) => SimpleDialog( title: const Text('Route path byte size'), children: [ - for (final value in [1, 2, 3]) + for (final value in RouteHashPreferences.supportedSizes) SimpleDialogOption( onPressed: () => Navigator.of(context).pop(value), child: Row( @@ -990,7 +990,7 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('$value byte${value == 1 ? '' : 's'}'), + Text('$value-byte (max ${64 ~/ value} hops)'), Text( 'Use ${value * 2} hex characters per hop', style: Theme.of(context).textTheme.bodySmall, diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index a48411a..a34c89c 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -20,6 +20,14 @@ class MeshMapNode { required this.updatedAtMs, }); + bool get hasValidCoordinates => + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180 && + latitude != 0 && + longitude != 0; + factory MeshMapNode.fromJson(Map json) { return MeshMapNode( type: (json['type'] as num?)?.toInt() ?? 0, @@ -77,9 +85,7 @@ class MeshMapNodesService { final nodes = nodesRaw .whereType>() .map(MeshMapNode.fromJson) - .where( - (n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0, - ) + .where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates) .toList(); await _storeCache(nodes, cachedAt: now); @@ -116,9 +122,7 @@ class MeshMapNodesService { final nodes = decoded .whereType>() .map(MeshMapNode.fromJson) - .where( - (n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0, - ) + .where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates) .toList(); _cachedNodes = nodes; _cachedAt = cachedAt; diff --git a/lib/services/route_hash_preferences.dart b/lib/services/route_hash_preferences.dart index 0c47bb2..d99e9ce 100644 --- a/lib/services/route_hash_preferences.dart +++ b/lib/services/route_hash_preferences.dart @@ -23,10 +23,13 @@ class RouteHashPreferences { static int normalizeSync(int value) => _normalize(value); + /// Valid hash sizes per the MeshCore protocol. + /// The path encoding uses 2 bits: 00=1B, 01=2B, 10=3B, 11=4B. + /// The official app offers 1, 2, 4 (skipping 3). + static const List supportedSizes = [1, 2, 4]; + static int _normalize(int value) { - if (value < 1 || value > 3) { - return defaultHashSize; - } - return value; + if (supportedSizes.contains(value)) return value; + return defaultHashSize; } } diff --git a/lib/widgets/contacts/contact_trace_sheet.dart b/lib/widgets/contacts/contact_trace_sheet.dart index 4dcaa06..4874b16 100644 --- a/lib/widgets/contacts/contact_trace_sheet.dart +++ b/lib/widgets/contacts/contact_trace_sheet.dart @@ -95,6 +95,7 @@ class _ContactTraceSheetState extends State { final concreteNodes = routeEntries .where((entry) => entry.resolved.node != null) .map((entry) => entry.resolved.node!) + .where((node) => node.hasValidCoordinates) .toList(); final mapPoints = concreteNodes .map((node) => LatLng(node.latitude, node.longitude)) @@ -395,6 +396,7 @@ class _ContactTraceSheetState extends State { ); }) .whereType() + .where((node) => node.hasValidCoordinates) .toList(); final selfNode = _selfNode(connectionProvider); @@ -415,7 +417,7 @@ class _ContactTraceSheetState extends State { return null; } - return MeshMapNode( + final node = MeshMapNode( type: -1, name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true ? connectionProvider.deviceInfo.selfName!.trim() @@ -428,6 +430,7 @@ class _ContactTraceSheetState extends State { longitude: advLon / 1e6, updatedAtMs: DateTime.now().millisecondsSinceEpoch, ); + return node.hasValidCoordinates ? node : null; } List _mergeNodes( diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index 8fa48a3..5cc6b5a 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -144,6 +144,7 @@ class _MessageTraceSheetState extends State { final concretePathNodes = routeEntries .where((entry) => entry.resolved.node != null) .map((entry) => entry.resolved.node!) + .where((node) => node.hasValidCoordinates) .toList(); final mapPoints = concretePathNodes .map((n) => LatLng(n.latitude, n.longitude)) @@ -390,6 +391,7 @@ class _MessageTraceSheetState extends State { ); }) .whereType() + .where((node) => node.hasValidCoordinates) .toList(); } diff --git a/pubspec.yaml b/pubspec.yaml index d166251..fb3c01b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "bcbeff09e6a56af3d54b884c4c503e30596195ab" + ref: "9b3d63c34ed0243becb76e306ee35be24760847d" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/models/custom_map_config_test.dart b/test/models/custom_map_config_test.dart new file mode 100644 index 0000000..6ca568d --- /dev/null +++ b/test/models/custom_map_config_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meshcore_sar_app/models/custom_map_config.dart'; + +void main() { + test('displayBounds stays within valid LatLng limits for tall images', () { + const config = CustomMapConfig( + filePath: '/tmp/map.png', + displayName: 'Tall Map', + mapId: 'map-id', + imageWidth: 2400, + imageHeight: 3213, + ); + + expect(config.displayBounds.north, lessThanOrEqualTo(90)); + expect(config.displayBounds.south, greaterThanOrEqualTo(-90)); + expect(config.displayBounds.east, lessThanOrEqualTo(180)); + expect(config.displayBounds.west, greaterThanOrEqualTo(-180)); + }); + + test('display point conversion preserves stored coordinates', () { + const config = CustomMapConfig( + filePath: '/tmp/map.png', + displayName: 'Tall Map', + mapId: 'map-id', + imageWidth: 2400, + imageHeight: 3213, + ); + const storedPoint = LatLng(1600, 1200); + + final displayPoint = config.toDisplayPoint(storedPoint); + final roundTrip = config.fromDisplayPoint(displayPoint); + + expect(roundTrip.latitude, closeTo(storedPoint.latitude, 0.001)); + expect(roundTrip.longitude, closeTo(storedPoint.longitude, 0.001)); + }); +} diff --git a/test/services/mesh_map_nodes_service_test.dart b/test/services/mesh_map_nodes_service_test.dart index f00d9a5..1b69359 100644 --- a/test/services/mesh_map_nodes_service_test.dart +++ b/test/services/mesh_map_nodes_service_test.dart @@ -94,4 +94,37 @@ void main() { expect(await MeshMapNodesService.loadCachedNodes(), isEmpty); expect(await MeshMapNodesService.cachedAt(), isNull); }); + + test('fetchNodes ignores nodes with invalid coordinates', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Broken', + 'public_key': 'bad123', + 'latitude': 3213.0, + 'longitude': 14.50, + 'updated_at': 123456, + }, + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ), + ); + + final nodes = await MeshMapNodesService.fetchNodes(client: client); + + expect(nodes, hasLength(1)); + expect(nodes.first.name, 'Alpha'); + }); }