mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +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:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@@ -26,22 +28,34 @@ class CustomMapConfig {
|
|||||||
|
|
||||||
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
||||||
|
|
||||||
LatLngBounds get bounds => LatLngBounds(
|
double get _displayScale {
|
||||||
const LatLng(0, 0),
|
final heightScale = imageHeight / 90.0;
|
||||||
LatLng(imageHeight.toDouble(), imageWidth.toDouble()),
|
final widthScale = imageWidth / 180.0;
|
||||||
);
|
return math.max(1.0, math.max(heightScale, widthScale));
|
||||||
|
}
|
||||||
|
|
||||||
LatLngBounds get displayBounds => LatLngBounds(
|
double get _displayHeight => imageHeight / _displayScale;
|
||||||
LatLng(imageHeight.toDouble(), 0),
|
|
||||||
LatLng(0, imageWidth.toDouble()),
|
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) {
|
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) {
|
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() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|||||||
@@ -735,7 +735,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.isContactMessage) {
|
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) {
|
if (message.isChannelMessage) {
|
||||||
|
|||||||
@@ -169,8 +169,13 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
for (final advert in adverts) {
|
for (final advert in adverts) {
|
||||||
if (!mounted) break;
|
if (!mounted) break;
|
||||||
|
// Skip already-resolved contacts
|
||||||
|
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await _resolveAdvert(advert);
|
await _resolveAdvert(advert);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -980,7 +980,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
builder: (context) => SimpleDialog(
|
builder: (context) => SimpleDialog(
|
||||||
title: const Text('Route path byte size'),
|
title: const Text('Route path byte size'),
|
||||||
children: [
|
children: [
|
||||||
for (final value in [1, 2, 3])
|
for (final value in RouteHashPreferences.supportedSizes)
|
||||||
SimpleDialogOption(
|
SimpleDialogOption(
|
||||||
onPressed: () => Navigator.of(context).pop(value),
|
onPressed: () => Navigator.of(context).pop(value),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -990,7 +990,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('$value byte${value == 1 ? '' : 's'}'),
|
Text('$value-byte (max ${64 ~/ value} hops)'),
|
||||||
Text(
|
Text(
|
||||||
'Use ${value * 2} hex characters per hop',
|
'Use ${value * 2} hex characters per hop',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ class MeshMapNode {
|
|||||||
required this.updatedAtMs,
|
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) {
|
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
|
||||||
return MeshMapNode(
|
return MeshMapNode(
|
||||||
type: (json['type'] as num?)?.toInt() ?? 0,
|
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||||
@@ -77,9 +85,7 @@ class MeshMapNodesService {
|
|||||||
final nodes = nodesRaw
|
final nodes = nodesRaw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(MeshMapNode.fromJson)
|
.map(MeshMapNode.fromJson)
|
||||||
.where(
|
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
await _storeCache(nodes, cachedAt: now);
|
await _storeCache(nodes, cachedAt: now);
|
||||||
@@ -116,9 +122,7 @@ class MeshMapNodesService {
|
|||||||
final nodes = decoded
|
final nodes = decoded
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(MeshMapNode.fromJson)
|
.map(MeshMapNode.fromJson)
|
||||||
.where(
|
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
_cachedNodes = nodes;
|
_cachedNodes = nodes;
|
||||||
_cachedAt = cachedAt;
|
_cachedAt = cachedAt;
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ class RouteHashPreferences {
|
|||||||
|
|
||||||
static int normalizeSync(int value) => _normalize(value);
|
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) {
|
static int _normalize(int value) {
|
||||||
if (value < 1 || value > 3) {
|
if (supportedSizes.contains(value)) return value;
|
||||||
return defaultHashSize;
|
return defaultHashSize;
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
final concreteNodes = routeEntries
|
final concreteNodes = routeEntries
|
||||||
.where((entry) => entry.resolved.node != null)
|
.where((entry) => entry.resolved.node != null)
|
||||||
.map((entry) => entry.resolved.node!)
|
.map((entry) => entry.resolved.node!)
|
||||||
|
.where((node) => node.hasValidCoordinates)
|
||||||
.toList();
|
.toList();
|
||||||
final mapPoints = concreteNodes
|
final mapPoints = concreteNodes
|
||||||
.map((node) => LatLng(node.latitude, node.longitude))
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
@@ -395,6 +396,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.whereType<MeshMapNode>()
|
.whereType<MeshMapNode>()
|
||||||
|
.where((node) => node.hasValidCoordinates)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final selfNode = _selfNode(connectionProvider);
|
final selfNode = _selfNode(connectionProvider);
|
||||||
@@ -415,7 +417,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return MeshMapNode(
|
final node = MeshMapNode(
|
||||||
type: -1,
|
type: -1,
|
||||||
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||||
? connectionProvider.deviceInfo.selfName!.trim()
|
? connectionProvider.deviceInfo.selfName!.trim()
|
||||||
@@ -428,6 +430,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
longitude: advLon / 1e6,
|
longitude: advLon / 1e6,
|
||||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
);
|
);
|
||||||
|
return node.hasValidCoordinates ? node : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MeshMapNode> _mergeNodes(
|
List<MeshMapNode> _mergeNodes(
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final concretePathNodes = routeEntries
|
final concretePathNodes = routeEntries
|
||||||
.where((entry) => entry.resolved.node != null)
|
.where((entry) => entry.resolved.node != null)
|
||||||
.map((entry) => entry.resolved.node!)
|
.map((entry) => entry.resolved.node!)
|
||||||
|
.where((node) => node.hasValidCoordinates)
|
||||||
.toList();
|
.toList();
|
||||||
final mapPoints = concretePathNodes
|
final mapPoints = concretePathNodes
|
||||||
.map((n) => LatLng(n.latitude, n.longitude))
|
.map((n) => LatLng(n.latitude, n.longitude))
|
||||||
@@ -390,6 +391,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.whereType<MeshMapNode>()
|
.whereType<MeshMapNode>()
|
||||||
|
.where((node) => node.hasValidCoordinates)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: "bcbeff09e6a56af3d54b884c4c503e30596195ab"
|
ref: "9b3d63c34ed0243becb76e306ee35be24760847d"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
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.loadCachedNodes(), isEmpty);
|
||||||
expect(await MeshMapNodesService.cachedAt(), isNull);
|
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