catch and correct possible bad values for lat/lon, add tests

This commit is contained in:
stopflock
2026-07-25 16:32:08 -05:00
parent b9110c1670
commit 1cfca663a6
5 changed files with 182 additions and 14 deletions
+23
View File
@@ -0,0 +1,23 @@
/// Shared validation helpers for geographic coordinates and zoom levels.
///
/// Used anywhere a [LatLng]-like value or zoom level comes from an external
/// or platform source (GPS fixes, persisted preferences, live map camera
/// state) that isn't guaranteed to be finite/in-range. Centralizing this
/// avoids subtly inconsistent validation logic being duplicated across the
/// GPS controller, map position persistence, and live camera guards.
library;
/// Validate that a latitude or longitude value is finite and within the
/// valid range for geographic coordinates (-180 to 180).
///
/// Note: this intentionally uses the wider +/-180 range (rather than +/-90
/// for latitude) so a single helper can validate both latitude and
/// longitude values without the caller needing to track which is which.
bool isValidCoordinate(double value) {
return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0;
}
/// Validate that a zoom level is finite and within a sane range.
bool isValidZoom(double zoom, {double min = 0.0, double max = 25.0}) {
return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max;
}
+18
View File
@@ -8,9 +8,11 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatf
import '../../dev_config.dart'; import '../../dev_config.dart';
import '../../app_state.dart' show FollowMeMode; import '../../app_state.dart' show FollowMeMode;
import '../../services/proximity_alert_service.dart'; import '../../services/proximity_alert_service.dart';
import '../../services/coordinate_validation.dart';
import '../../models/osm_node.dart'; import '../../models/osm_node.dart';
import '../../models/node_profile.dart'; import '../../models/node_profile.dart';
/// Simple GPS controller that handles precise location permissions only. /// Simple GPS controller that handles precise location permissions only.
/// Key principles: /// Key principles:
/// - Respect "denied forever" - stop trying /// - Respect "denied forever" - stop trying
@@ -187,6 +189,21 @@ class GpsController {
/// Handle incoming GPS position /// Handle incoming GPS position
void _onPositionReceived(Position position) { void _onPositionReceived(Position position) {
// Guard against malformed fixes from the platform location stack (some
// OEM/fused location providers occasionally emit NaN/Infinite lat/lng,
// especially while a fix is still being acquired). An unvalidated bad
// value here can crash the app at launch (via initialCenter) or corrupt
// the live map camera (via follow-me animateTo), so treat it like any
// other transient location error rather than trusting it.
if (!isValidCoordinate(position.latitude) || !isValidCoordinate(position.longitude)) {
debugPrint(
'[GpsController] Ignoring invalid GPS position: '
'lat=${position.latitude}, lng=${position.longitude}',
);
return;
}
final newLocation = LatLng(position.latitude, position.longitude); final newLocation = LatLng(position.latitude, position.longitude);
_currentLocation = newLocation; _currentLocation = newLocation;
@@ -209,6 +226,7 @@ class GpsController {
} }
/// Handle GPS stream errors /// Handle GPS stream errors
void _onPositionError(dynamic error) { void _onPositionError(dynamic error) {
debugPrint('[GpsController] Position stream error: $error'); debugPrint('[GpsController] Position stream error: $error');
if (_hasLocation) { if (_hasLocation) {
+12 -13
View File
@@ -3,6 +3,9 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../services/coordinate_validation.dart' as coord_validation;
/// Manages map position persistence and initial positioning. /// Manages map position persistence and initial positioning.
/// Handles saving/loading last map position and moving to initial locations. /// Handles saving/loading last map position and moving to initial locations.
@@ -30,7 +33,8 @@ class MapPositionManager {
final zoom = prefs.getDouble('last_map_zoom'); final zoom = prefs.getDouble('last_map_zoom');
if (lat != null && lng != null && if (lat != null && lng != null &&
_isValidCoordinate(lat) && _isValidCoordinate(lng)) { coord_validation.isValidCoordinate(lat) && coord_validation.isValidCoordinate(lng)) {
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0; final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
_initialLocation = LatLng(lat, lng); _initialLocation = LatLng(lat, lng);
_initialZoom = validZoom; _initialZoom = validZoom;
@@ -50,9 +54,10 @@ class MapPositionManager {
try { try {
final zoom = _initialZoom ?? 15.0; final zoom = _initialZoom ?? 15.0;
// Double-check coordinates are valid before moving // Double-check coordinates are valid before moving
if (_isValidCoordinate(_initialLocation!.latitude) && if (coord_validation.isValidCoordinate(_initialLocation!.latitude) &&
_isValidCoordinate(_initialLocation!.longitude) && coord_validation.isValidCoordinate(_initialLocation!.longitude) &&
_isValidZoom(zoom)) { _isValidZoom(zoom)) {
controller.mapController.move(_initialLocation!, zoom); controller.mapController.move(_initialLocation!, zoom);
_hasMovedToInitialLocation = true; _hasMovedToInitialLocation = true;
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}'); debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
@@ -70,9 +75,10 @@ class MapPositionManager {
Future<void> saveMapPosition(LatLng location, double zoom) async { Future<void> saveMapPosition(LatLng location, double zoom) async {
try { try {
// Validate coordinates and zoom before saving // Validate coordinates and zoom before saving
if (!_isValidCoordinate(location.latitude) || if (!coord_validation.isValidCoordinate(location.latitude) ||
!_isValidCoordinate(location.longitude) || !coord_validation.isValidCoordinate(location.longitude) ||
!_isValidZoom(zoom)) { !_isValidZoom(zoom)) {
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom'); debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
return; return;
} }
@@ -102,15 +108,8 @@ class MapPositionManager {
} }
} }
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
bool _isValidCoordinate(double value) {
return !value.isNaN &&
!value.isInfinite &&
value >= -180.0 &&
value <= 180.0;
}
/// Validate that a zoom level is valid /// Validate that a zoom level is valid
bool _isValidZoom(double zoom) { bool _isValidZoom(double zoom) {
return !zoom.isNaN && return !zoom.isNaN &&
!zoom.isInfinite && !zoom.isInfinite &&
+73 -1
View File
@@ -26,9 +26,12 @@ import 'node_limit_indicator.dart';
import 'proximity_alert_banner.dart'; import 'proximity_alert_banner.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import '../services/proximity_alert_service.dart'; import '../services/proximity_alert_service.dart';
import '../services/coordinate_validation.dart';
import 'sheet_aware_map.dart'; import 'sheet_aware_map.dart';
import 'custom_scale_bar.dart'; import 'custom_scale_bar.dart';
class MapView extends StatefulWidget { class MapView extends StatefulWidget {
final AnimatedMapController controller; final AnimatedMapController controller;
const MapView({ const MapView({
@@ -80,6 +83,15 @@ class MapViewState extends State<MapView> {
// Track map center to clear queue on significant panning // Track map center to clear queue on significant panning
LatLng? _lastCenter; LatLng? _lastCenter;
// Track the last known-good (finite, in-range) camera center/zoom so we
// can self-heal if the camera ever gets pushed into a degenerate state
// (e.g. by an edge case in flutter_map's internal gesture-to-camera
// pipeline). This mirrors the validation used for persisted map position
// in MapPositionManager, applied to the *live* camera as well.
LatLng? _lastValidCenter;
double? _lastValidZoom;
// State for proximity alert banner // State for proximity alert banner
bool _showProximityBanner = false; bool _showProximityBanner = false;
@@ -275,6 +287,29 @@ class MapViewState extends State<MapView> {
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0; return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
} }
/// Get a known-good fallback location for [initialCenter], preferring a
/// validated GPS location, then a validated persisted position, then a
/// hardcoded default. GpsController already validates incoming fixes, but
/// this extra check keeps launch resilient even if that guard is ever
/// bypassed (e.g. future refactor).
LatLng get _safeInitialCenter {
final gpsLocation = _gpsController.currentLocation;
if (gpsLocation != null &&
isValidCoordinate(gpsLocation.latitude) &&
isValidCoordinate(gpsLocation.longitude)) {
return gpsLocation;
}
final persisted = _positionManager.initialLocation;
if (persisted != null &&
isValidCoordinate(persisted.latitude) &&
isValidCoordinate(persisted.longitude)) {
return persisted;
}
return LatLng(37.7749, -122.4194);
}
@override @override
void didUpdateWidget(covariant MapView oldWidget) { void didUpdateWidget(covariant MapView oldWidget) {
@@ -414,18 +449,55 @@ class MapViewState extends State<MapView> {
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController, mapController: _controller.mapController,
options: MapOptions( options: MapOptions(
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), initialCenter: _safeInitialCenter,
initialZoom: _positionManager.initialZoom ?? 15, initialZoom: _positionManager.initialZoom ?? 15,
minZoom: 1.0, minZoom: 1.0,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(), maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
interactionOptions: _interactionManager.getInteractionOptions(editSession), interactionOptions: _interactionManager.getInteractionOptions(editSession),
onPositionChanged: (pos, gesture) { onPositionChanged: (pos, gesture) {
// Self-heal if the camera has been pushed into a degenerate
// state (non-finite or wildly out-of-range center/zoom). This
// has been observed to occur silently during ordinary panning
// (no crash, no console output) — likely an edge case in
// flutter_map's internal gesture-to-camera math — leaving the
// map showing a blank grey view with no tiles or markers,
// since every subsequent gesture computes its new position as
// a delta from the already-broken center. Snap back to the
// last known-good position the moment this is detected rather
// than requiring the user to discover the GPS button "fixes"
// it (which works only because it supplies a fresh valid
// destination).
if (!isValidCoordinate(pos.center.latitude) ||
!isValidCoordinate(pos.center.longitude) ||
!isValidZoom(pos.zoom)) {
debugPrint(
'[MapView] Detected invalid camera position '
'(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) '
'- snapping back to last known-good position',
);
final recoveryCenter = _lastValidCenter ?? _safeInitialCenter;
final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0);
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_controller.mapController.move(recoveryCenter, recoveryZoom);
} catch (e) {
debugPrint('[MapView] Failed to recover camera position: $e');
}
});
return; // Don't process this invalid position further
}
_lastValidCenter = pos.center;
_lastValidZoom = pos.zoom;
setState(() {}); // Instant UI update for zoom, etc. setState(() {}); // Instant UI update for zoom, etc.
if (gesture) { if (gesture) {
widget.onUserGesture(); widget.onUserGesture();
} }
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet) // Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) { if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
// User tried to zoom out below minimum - snap back to minimum zoom // User tried to zoom out below minimum - snap back to minimum zoom
_controller.animateTo( _controller.animateTo(
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:deflockapp/services/coordinate_validation.dart';
void main() {
group('isValidCoordinate', () {
test('accepts normal in-range values', () {
expect(isValidCoordinate(0), isTrue);
expect(isValidCoordinate(37.7749), isTrue);
expect(isValidCoordinate(-122.4194), isTrue);
expect(isValidCoordinate(180), isTrue);
expect(isValidCoordinate(-180), isTrue);
});
test('rejects NaN', () {
expect(isValidCoordinate(double.nan), isFalse);
});
test('rejects positive and negative infinity', () {
expect(isValidCoordinate(double.infinity), isFalse);
expect(isValidCoordinate(double.negativeInfinity), isFalse);
});
test('rejects out-of-range values', () {
expect(isValidCoordinate(180.1), isFalse);
expect(isValidCoordinate(-180.1), isFalse);
expect(isValidCoordinate(1000), isFalse);
});
});
group('isValidZoom', () {
test('accepts normal in-range values', () {
expect(isValidZoom(0), isTrue);
expect(isValidZoom(15), isTrue);
expect(isValidZoom(25), isTrue);
});
test('rejects NaN and Infinite', () {
expect(isValidZoom(double.nan), isFalse);
expect(isValidZoom(double.infinity), isFalse);
expect(isValidZoom(double.negativeInfinity), isFalse);
});
test('rejects out-of-range values with default bounds', () {
expect(isValidZoom(-1), isFalse);
expect(isValidZoom(26), isFalse);
});
test('respects custom min/max bounds', () {
expect(isValidZoom(0.5, min: 1.0, max: 20.0), isFalse);
expect(isValidZoom(1.0, min: 1.0, max: 20.0), isTrue);
expect(isValidZoom(20.0, min: 1.0, max: 20.0), isTrue);
expect(isValidZoom(20.1, min: 1.0, max: 20.0), isFalse);
});
});
}