mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
fix: Prevent invalid map bounds #123
This commit is contained in:
@@ -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<String, dynamic> toJson() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -169,8 +169,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
for (final advert in adverts) {
|
||||
if (!mounted) break;
|
||||
// Skip already-resolved contacts
|
||||
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||
continue;
|
||||
}
|
||||
await _resolveAdvert(advert);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -980,7 +980,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<String, dynamic> json) {
|
||||
return MeshMapNode(
|
||||
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||
@@ -77,9 +85,7 @@ class MeshMapNodesService {
|
||||
final nodes = nodesRaw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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<String, dynamic>>()
|
||||
.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;
|
||||
|
||||
@@ -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<int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||
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<ContactTraceSheet> {
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
|
||||
final selfNode = _selfNode(connectionProvider);
|
||||
@@ -415,7 +417,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||
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<ContactTraceSheet> {
|
||||
longitude: advLon / 1e6,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
return node.hasValidCoordinates ? node : null;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
|
||||
@@ -144,6 +144,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
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<MessageTraceSheet> {
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.where((node) => node.hasValidCoordinates)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
37
test/models/custom_map_config_test.dart
Normal file
37
test/models/custom_map_config_test.dart
Normal file
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user