mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Enhance device configuration and location tracking features
- Refetch device info after updating settings in device_config_screen.dart. - Update map_tab.dart to use singleton instance of LocationTrackingService and streamline location tracking callbacks. - Modify ble_response_handler.dart to handle contact not found errors and improve error callback structure. - Enhance location_tracking_service.dart with retry logic for GPS position acquisition and initial position setting without broadcasting. - Update meshcore_ble_service.dart to track last contact for auto-recovery on errors. - Improve tile_cache_service.dart error messages and streamline tile download logic. - Add current GPS location insertion feature in direct_message_sheet.dart with permission checks. - Update pubspec.lock and pubspec.yaml to include integration_test dependency. - Add screenshot automation script for iOS and Android devices. - Create integration test driver for screenshot capturing.
This commit is contained in:
@@ -29,7 +29,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
|
||||
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
|
||||
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
|
||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
|
||||
/// Processes incoming responses from the BLE device
|
||||
class BleResponseHandler {
|
||||
@@ -58,8 +59,12 @@ class BleResponseHandler {
|
||||
OnBinaryResponseCallback? onBinaryResponse;
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
VoidCallback? onRxActivity;
|
||||
|
||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||
Uint8List? _lastContactPublicKey;
|
||||
|
||||
// Getters
|
||||
int get rxPacketCount => _rxPacketCount;
|
||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||
@@ -574,13 +579,25 @@ class BleResponseHandler {
|
||||
if (errorCode != null) {
|
||||
final errorMsg = FrameParser.getErrorMessage(errorCode);
|
||||
print(' ❌ [Error] $errorMsg');
|
||||
onError?.call(errorMsg);
|
||||
|
||||
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
||||
onContactNotFound?.call(_lastContactPublicKey);
|
||||
}
|
||||
|
||||
onError?.call(errorMsg, errorCode: errorCode);
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ [Error] Parsing error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the last contact public key for retry logic
|
||||
void setLastContactPublicKey(Uint8List? publicKey) {
|
||||
_lastContactPublicKey = publicKey;
|
||||
}
|
||||
|
||||
/// Log a packet
|
||||
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
|
||||
_packetLogs.add(BlePacketLog(
|
||||
|
||||
@@ -77,6 +77,9 @@ class LocationTrackingService {
|
||||
/// Whether service has been initialized with BLE service
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// Whether the first stable position has been set (without broadcast)
|
||||
bool _firstPositionSet = false;
|
||||
|
||||
// ============================================================================
|
||||
// Private Properties
|
||||
// ============================================================================
|
||||
@@ -172,22 +175,52 @@ class LocationTrackingService {
|
||||
/// Get current GPS position
|
||||
///
|
||||
/// Returns null if position unavailable or permissions denied.
|
||||
Future<Position?> getCurrentPosition() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
/// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
|
||||
/// [retryCount] - Number of retry attempts (default: 2)
|
||||
Future<Position?> getCurrentPosition({
|
||||
Duration timeLimit = const Duration(seconds: 15),
|
||||
int retryCount = 2,
|
||||
}) async {
|
||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
||||
// Exponential backoff: wait 2^attempt seconds before retry
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
|
||||
currentPosition = position;
|
||||
return position;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Error getting position: $e');
|
||||
onError?.call('Failed to get current position: $e');
|
||||
return null;
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: timeLimit,
|
||||
),
|
||||
);
|
||||
|
||||
currentPosition = position;
|
||||
if (attempt > 0) {
|
||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
||||
}
|
||||
return position;
|
||||
} catch (e) {
|
||||
final isLastAttempt = attempt == retryCount;
|
||||
if (isLastAttempt) {
|
||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
||||
// Only call error callback on final failure, and make it user-friendly
|
||||
if (e.toString().contains('TimeoutException')) {
|
||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
||||
} else {
|
||||
onError?.call('Failed to get GPS position. Check device settings.');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
||||
}
|
||||
|
||||
if (isLastAttempt) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get position stream with configurable distance filter
|
||||
@@ -211,6 +244,8 @@ class LocationTrackingService {
|
||||
/// [distanceThreshold] - GPS update distance filter
|
||||
///
|
||||
/// Returns true if successful, false otherwise.
|
||||
/// Note: This method returns immediately after starting the position stream.
|
||||
/// Initial position acquisition happens asynchronously in the background.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint(
|
||||
@@ -239,17 +274,28 @@ class LocationTrackingService {
|
||||
// Save settings
|
||||
await saveSettings();
|
||||
|
||||
// Get initial position
|
||||
await getCurrentPosition();
|
||||
// Try to get initial position in background (non-blocking)
|
||||
// This will populate currentPosition but won't block tracking startup
|
||||
getCurrentPosition(
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
retryCount: 1,
|
||||
).then((position) {
|
||||
if (position != null) {
|
||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
||||
}
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
||||
// Not critical - position stream will eventually provide position
|
||||
});
|
||||
|
||||
// Start position stream
|
||||
// Start position stream immediately (don't wait for initial position)
|
||||
try {
|
||||
_positionSubscription = getPositionStream(distanceFilter: threshold)
|
||||
.listen(
|
||||
_handlePositionUpdate,
|
||||
onError: (error) {
|
||||
debugPrint('❌ [LocationTracking] Position stream error: $error');
|
||||
onError?.call('Position stream error: $error');
|
||||
onError?.call('GPS stream error. Retrying...');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -259,10 +305,11 @@ class LocationTrackingService {
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||
);
|
||||
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
|
||||
onError?.call('Failed to start tracking: $e');
|
||||
onError?.call('Failed to start GPS tracking: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +324,9 @@ class LocationTrackingService {
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
|
||||
// Reset first position flag so next connection starts fresh
|
||||
_firstPositionSet = false;
|
||||
|
||||
// Save disabled state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
@@ -316,10 +366,57 @@ class LocationTrackingService {
|
||||
// Notify listeners
|
||||
onPositionUpdate?.call(position);
|
||||
|
||||
// SPECIAL CASE: First stable position after connection
|
||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||
if (!_firstPositionSet) {
|
||||
_setInitialPosition(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should broadcast to mesh network
|
||||
_checkAndBroadcast(position);
|
||||
}
|
||||
|
||||
/// Set initial position on device without broadcasting
|
||||
///
|
||||
/// Called only for the first stable GPS position after connection starts.
|
||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||
void _setInitialPosition(Position position) async {
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
||||
|
||||
// Update device's advertised location WITHOUT sending advertisement
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Mark first position as set
|
||||
_firstPositionSet = true;
|
||||
|
||||
// Update last broadcast position to prevent immediate broadcast on next update
|
||||
_lastBroadcastPosition = position;
|
||||
_lastBroadcastTime = DateTime.now();
|
||||
|
||||
// Save to preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
||||
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [LocationTracking] Failed to set initial position: $e');
|
||||
onError?.call('Failed to set initial position: $e');
|
||||
// Don't mark as set on failure, so it will retry on next update
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if position should be broadcast based on distance and time thresholds
|
||||
void _checkAndBroadcast(Position position) {
|
||||
// If never broadcast before, do it now
|
||||
|
||||
@@ -28,7 +28,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
|
||||
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
|
||||
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
|
||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||
typedef OnErrorCallback = void Function(String error);
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||
@@ -62,6 +63,7 @@ class MeshCoreBleService {
|
||||
OnBinaryResponseCallback? onBinaryResponse;
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
|
||||
// Activity callbacks (for blinking indicators)
|
||||
VoidCallback? onRxActivity;
|
||||
@@ -149,8 +151,11 @@ class MeshCoreBleService {
|
||||
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
|
||||
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
|
||||
};
|
||||
_responseHandler.onError = (error) {
|
||||
onError?.call(error);
|
||||
_responseHandler.onError = (error, {int? errorCode}) {
|
||||
onError?.call(error, errorCode: errorCode);
|
||||
};
|
||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
||||
onContactNotFound?.call(contactPublicKey);
|
||||
};
|
||||
_responseHandler.onRxActivity = () {
|
||||
onRxActivity?.call();
|
||||
@@ -247,6 +252,9 @@ class MeshCoreBleService {
|
||||
throw ArgumentError('Text message exceeds 160 character limit');
|
||||
}
|
||||
|
||||
// Track the last contact for auto-recovery if contact not found
|
||||
_responseHandler.setLastContactPublicKey(contactPublicKey);
|
||||
|
||||
await _commandSender.writeData(FrameBuilder.buildSendTxtMsg(
|
||||
contactPublicKey: contactPublicKey,
|
||||
text: text,
|
||||
|
||||
@@ -47,7 +47,9 @@ class TileCacheService {
|
||||
|
||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
return _store.getTileProvider(
|
||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
||||
@@ -63,7 +65,9 @@ class TileCacheService {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (_isDownloading) {
|
||||
@@ -78,21 +82,19 @@ class TileCacheService {
|
||||
final downloadable = region.toDownloadable(
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
options: TileLayer(
|
||||
urlTemplate: layer.urlTemplate,
|
||||
),
|
||||
options: TileLayer(urlTemplate: layer.urlTemplate),
|
||||
);
|
||||
|
||||
final download = _store.download.startForeground(
|
||||
region: downloadable,
|
||||
);
|
||||
final download = _store.download.startForeground(region: downloadable);
|
||||
|
||||
await for (final progress in download.downloadProgress) {
|
||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
||||
// Use attemptedTilesCount instead of successfulTilesCount
|
||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
||||
final percentage = progress.percentageProgress;
|
||||
print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})');
|
||||
print(
|
||||
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
|
||||
);
|
||||
onProgress(percentage);
|
||||
}
|
||||
}
|
||||
@@ -126,7 +128,9 @@ class TileCacheService {
|
||||
|
||||
Future<List<String>> getAvailableStores() async {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||
throw StateError(
|
||||
'TileCacheService not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
|
||||
final stores = await FMTCRoot.stats.storesAvailable;
|
||||
@@ -137,11 +141,11 @@ class TileCacheService {
|
||||
if (!_isInitialized) return {};
|
||||
|
||||
final length = await _store.stats.length;
|
||||
final size = await _store.stats.size;
|
||||
final size = await _store.stats.all.then((a) => a.size);
|
||||
|
||||
return {
|
||||
'tileCount': length,
|
||||
'sizeMB': size / (1024 * 1024),
|
||||
'sizeMB': size / 1024,
|
||||
'storeName': _storeName,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user