mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-10 21:20:18 +02:00
catch and correct possible bad values for lat/lon, add tests
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user