mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
150
lib/services/app_config_snapshot_service.dart
Normal file
150
lib/services/app_config_snapshot_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'image_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'route_hash_preferences.dart';
|
||||
import 'voice_bitrate_preferences.dart';
|
||||
|
||||
class AppConfigSnapshotService {
|
||||
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: appProvider.isMapEnabled,
|
||||
contactsEnabled: appProvider.isContactsEnabled,
|
||||
sensorsEnabled: appProvider.isSensorsEnabled,
|
||||
voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
|
||||
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
messageFontScale: appProvider.messageFontScale,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
|
||||
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
|
||||
routeHashSize: await RouteHashPreferences.getHashSize(),
|
||||
imageMaxSize: await ImagePreferences.getMaxSize(),
|
||||
imageCompression: await ImagePreferences.getCompression(),
|
||||
imageGrayscale: await ImagePreferences.getGrayscale(),
|
||||
imageUltraMode: await ImagePreferences.getUltraMode(),
|
||||
showRxTxIndicators:
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true,
|
||||
fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled,
|
||||
fastLocationMovementThresholdMeters:
|
||||
locationTracking.fastLocationMovementThresholdMeters,
|
||||
fastLocationActiveCadenceSeconds:
|
||||
locationTracking.fastLocationActiveCadenceSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
AppSettingsProfileSection? section,
|
||||
AppProvider appProvider,
|
||||
) async {
|
||||
if (section == null || section.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (section.mapEnabled != null) {
|
||||
await appProvider.toggleMapEnabled(section.mapEnabled!);
|
||||
}
|
||||
if (section.contactsEnabled != null) {
|
||||
await appProvider.toggleContactsEnabled(section.contactsEnabled!);
|
||||
}
|
||||
if (section.sensorsEnabled != null) {
|
||||
await appProvider.toggleSensorsEnabled(section.sensorsEnabled!);
|
||||
}
|
||||
if (section.voiceSilenceTrimmingEnabled != null) {
|
||||
await appProvider.toggleVoiceSilenceTrimmingEnabled(
|
||||
section.voiceSilenceTrimmingEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBandPassFilterEnabled != null) {
|
||||
await appProvider.toggleVoiceBandPassFilterEnabled(
|
||||
section.voiceBandPassFilterEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceCompressorEnabled != null) {
|
||||
await appProvider.toggleVoiceCompressorEnabled(
|
||||
section.voiceCompressorEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceLimiterEnabled != null) {
|
||||
await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!);
|
||||
}
|
||||
if (section.voiceAutoGainEnabled != null) {
|
||||
await appProvider.toggleVoiceAutoGainEnabled(
|
||||
section.voiceAutoGainEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceEchoCancellationEnabled != null) {
|
||||
await appProvider.toggleVoiceEchoCancellationEnabled(
|
||||
section.voiceEchoCancellationEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceNoiseSuppressionEnabled != null) {
|
||||
await appProvider.toggleVoiceNoiseSuppressionEnabled(
|
||||
section.voiceNoiseSuppressionEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.messageFontScale != null) {
|
||||
await appProvider.setMessageFontScale(section.messageFontScale!);
|
||||
}
|
||||
if (section.clearPathOnMaxRetry != null) {
|
||||
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
|
||||
}
|
||||
if (section.nearestRelayFallbackEnabled != null) {
|
||||
await appProvider.toggleNearestRelayFallbackEnabled(
|
||||
section.nearestRelayFallbackEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBitrate != null) {
|
||||
await VoiceBitratePreferences.setBitrate(section.voiceBitrate!);
|
||||
}
|
||||
if (section.routeHashSize != null) {
|
||||
await RouteHashPreferences.setHashSize(section.routeHashSize!);
|
||||
}
|
||||
if (section.imageMaxSize != null) {
|
||||
await ImagePreferences.setMaxSize(section.imageMaxSize!);
|
||||
}
|
||||
if (section.imageCompression != null) {
|
||||
await ImagePreferences.setCompression(section.imageCompression!);
|
||||
}
|
||||
if (section.imageGrayscale != null) {
|
||||
await ImagePreferences.setGrayscale(section.imageGrayscale!);
|
||||
}
|
||||
if (section.imageUltraMode != null) {
|
||||
await ImagePreferences.setUltraMode(section.imageUltraMode!);
|
||||
}
|
||||
if (section.showRxTxIndicators != null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
section.showRxTxIndicators!,
|
||||
);
|
||||
}
|
||||
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
if (section.fastLocationUpdatesEnabled != null) {
|
||||
locationTracking.fastLocationUpdatesEnabled =
|
||||
section.fastLocationUpdatesEnabled!;
|
||||
}
|
||||
if (section.fastLocationMovementThresholdMeters != null) {
|
||||
locationTracking.fastLocationMovementThresholdMeters =
|
||||
section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
|
||||
}
|
||||
if (section.fastLocationActiveCadenceSeconds != null) {
|
||||
locationTracking.fastLocationActiveCadenceSeconds =
|
||||
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
|
||||
}
|
||||
await locationTracking.saveSettings();
|
||||
await appProvider.reloadProfileScopedSettings();
|
||||
}
|
||||
}
|
||||
334
lib/services/background_location_service.dart
Normal file
334
lib/services/background_location_service.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Background location tracking service for SAR operations
|
||||
/// Tracks user location and sends periodic updates via MeshCore BLE
|
||||
@pragma('vm:entry-point')
|
||||
class BackgroundLocationService {
|
||||
static const String _prefKeyEnabled = 'background_tracking_enabled';
|
||||
static const String _prefKeyDistance = 'background_tracking_distance';
|
||||
static const String _prefKeyLastLat = 'background_last_lat';
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
static const String _notificationChannelId =
|
||||
'meshcore_sar_background_tracking';
|
||||
static const int _notificationId = 9101;
|
||||
|
||||
MeshCoreBleService? _bleService;
|
||||
final FlutterBackgroundService _service = FlutterBackgroundService();
|
||||
bool _serviceConfigured = false;
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
bool _isInitialized = false;
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
|
||||
/// Initialize the service with BLE service reference
|
||||
void initialize(MeshCoreBleService bleService) {
|
||||
_bleService = bleService;
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// Start location tracking and automatic advertisement
|
||||
/// Returns true if successful, false otherwise
|
||||
///
|
||||
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
|
||||
if (!_isInitialized || _bleService == null) {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] Service not initialized or BLE service null',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [BackgroundLocation] BLE not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check location permissions
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Location permission denied');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] Location permission permanently denied',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission != LocationPermission.always) {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
|
||||
|
||||
await _startForegroundService(distanceThreshold);
|
||||
|
||||
// Start listening to position updates
|
||||
Position? lastPosition;
|
||||
try {
|
||||
_positionSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: _buildLocationSettings(
|
||||
distanceFilter: distanceThreshold.toInt(),
|
||||
),
|
||||
).listen((Position position) async {
|
||||
debugPrint(
|
||||
'📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}',
|
||||
);
|
||||
|
||||
// Calculate distance from last position
|
||||
if (lastPosition != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
lastPosition!.latitude,
|
||||
lastPosition!.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)',
|
||||
);
|
||||
|
||||
// Skip if haven't moved enough
|
||||
if (distance < distanceThreshold) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last position
|
||||
lastPosition = position;
|
||||
|
||||
// Save to preferences
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_prefKeyLastLat),
|
||||
position.latitude,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_prefKeyLastLon),
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
// Update device's advertised location
|
||||
if (_bleService != null && _bleService!.isConnected) {
|
||||
try {
|
||||
debugPrint(
|
||||
'📤 [BackgroundLocation] Updating device location...',
|
||||
);
|
||||
await _bleService!.setAdvertLatLon(
|
||||
latitude: position.latitude,
|
||||
longitude: position.longitude,
|
||||
);
|
||||
|
||||
// Send advertisement to mesh network
|
||||
debugPrint(
|
||||
'📡 [BackgroundLocation] Broadcasting self advertisement...',
|
||||
);
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
debugPrint(
|
||||
'✅ [BackgroundLocation] Location update sent successfully',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'❌ [BackgroundLocation] Failed to send location update: $e',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] BLE disconnected, cannot send update',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop location tracking
|
||||
Future<void> stopTracking() async {
|
||||
debugPrint('🛑 [BackgroundLocation] Stopping tracking');
|
||||
await _positionSubscription?.cancel();
|
||||
_positionSubscription = null;
|
||||
await _stopForegroundService();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
||||
}
|
||||
|
||||
Future<void> _startForegroundService(double distanceThreshold) async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!_serviceConfigured) {
|
||||
await _configureForegroundService();
|
||||
}
|
||||
|
||||
final running = await _service.isRunning();
|
||||
if (!running) {
|
||||
await _service.startService();
|
||||
}
|
||||
|
||||
_service.invoke('trackingUpdate', {
|
||||
'distanceThreshold': distanceThreshold,
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service start failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopForegroundService() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await _service.isRunning()) {
|
||||
_service.invoke('stopService');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [BackgroundLocation] Foreground service stop failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configureForegroundService() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
_notificationChannelId,
|
||||
'Background tracking',
|
||||
description:
|
||||
'Keeps MeshCore SAR location sharing active while the app is in the background.',
|
||||
importance: Importance.low,
|
||||
);
|
||||
|
||||
final notifications = FlutterLocalNotificationsPlugin();
|
||||
await notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
await _service.configure(
|
||||
androidConfiguration: AndroidConfiguration(
|
||||
onStart: meshCoreSarBackgroundServiceStart,
|
||||
autoStart: false,
|
||||
autoStartOnBoot: false,
|
||||
isForegroundMode: true,
|
||||
notificationChannelId: _notificationChannelId,
|
||||
initialNotificationTitle: 'MeshCore SAR',
|
||||
initialNotificationContent: 'Maintaining background tracking',
|
||||
foregroundServiceNotificationId: _notificationId,
|
||||
foregroundServiceTypes: [AndroidForegroundType.location],
|
||||
),
|
||||
iosConfiguration: IosConfiguration(
|
||||
autoStart: false,
|
||||
onForeground: meshCoreSarBackgroundServiceStart,
|
||||
onBackground: meshCoreSarBackgroundServiceIos,
|
||||
),
|
||||
);
|
||||
_serviceConfigured = true;
|
||||
}
|
||||
|
||||
/// Update the distance threshold for location updates
|
||||
/// Note: This will restart tracking with the new threshold
|
||||
Future<void> updateDistanceThreshold(double distance) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distance);
|
||||
debugPrint(
|
||||
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
||||
);
|
||||
|
||||
// Restart tracking if currently enabled
|
||||
final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
|
||||
if (isEnabled && _bleService != null) {
|
||||
await stopTracking();
|
||||
await startTracking(distanceThreshold: distance);
|
||||
}
|
||||
}
|
||||
|
||||
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||
if (Platform.isIOS) {
|
||||
return AppleSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
activityType: ActivityType.fitness,
|
||||
allowBackgroundLocationUpdates: true,
|
||||
distanceFilter: distanceFilter,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
);
|
||||
}
|
||||
|
||||
return LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<bool> meshCoreSarBackgroundServiceIos(ServiceInstance service) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
return true;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void meshCoreSarBackgroundServiceStart(ServiceInstance service) {
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
if (service is AndroidServiceInstance) {
|
||||
service.setAsForegroundService();
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking',
|
||||
);
|
||||
}
|
||||
|
||||
service.on('trackingUpdate').listen((event) {
|
||||
if (service is AndroidServiceInstance) {
|
||||
final threshold = event?['distanceThreshold'];
|
||||
final suffix = threshold is num
|
||||
? ' (${threshold.toStringAsFixed(0)} m updates)'
|
||||
: '';
|
||||
service.setForegroundNotificationInfo(
|
||||
title: 'MeshCore SAR',
|
||||
content: 'Maintaining background tracking$suffix',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
service.on('stopService').listen((event) {
|
||||
service.stopSelf();
|
||||
});
|
||||
}
|
||||
178
lib/services/bthome_met_history.dart
Normal file
178
lib/services/bthome_met_history.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
enum BTHomeMetMeasurement {
|
||||
temperature(1, 'Temperature', '°C'),
|
||||
humidity(2, 'Humidity', '%'),
|
||||
windSpeed(3, 'Wind speed', 'm/s'),
|
||||
gust(4, 'Wind gust', 'm/s'),
|
||||
rain(5, 'Rain', 'mm');
|
||||
|
||||
const BTHomeMetMeasurement(this.id, this.label, this.unit);
|
||||
|
||||
final int id;
|
||||
final String label;
|
||||
final String unit;
|
||||
|
||||
static BTHomeMetMeasurement? fromId(int id) {
|
||||
for (final value in BTHomeMetMeasurement.values) {
|
||||
if (value.id == id) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryPage {
|
||||
const BTHomeMetHistoryPage({
|
||||
required this.measurement,
|
||||
required this.page,
|
||||
required this.values,
|
||||
});
|
||||
|
||||
final BTHomeMetMeasurement measurement;
|
||||
final int page;
|
||||
final List<double> values;
|
||||
|
||||
double? get latest => values.isEmpty ? null : values.last;
|
||||
double? get minimum => values.isEmpty
|
||||
? null
|
||||
: values.reduce((left, right) => left < right ? left : right);
|
||||
double? get maximum => values.isEmpty
|
||||
? null
|
||||
: values.reduce((left, right) => left > right ? left : right);
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryFormatException implements Exception {
|
||||
const BTHomeMetHistoryFormatException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class BTHomeMetHistoryParser {
|
||||
static BTHomeMetHistoryPage parse(String text) {
|
||||
final parts = text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.toList(growable: false);
|
||||
if (parts.length < 3) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history response is too short.',
|
||||
);
|
||||
}
|
||||
|
||||
final measurementId = int.tryParse(parts[0]);
|
||||
final page = int.tryParse(parts[1]);
|
||||
final count = int.tryParse(parts[2]);
|
||||
if (measurementId == null || page == null || count == null) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history header is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
final measurement = BTHomeMetMeasurement.fromId(measurementId);
|
||||
if (measurement == null) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'Unsupported MET history measurement id: $measurementId',
|
||||
);
|
||||
}
|
||||
if (count < 0) {
|
||||
throw const BTHomeMetHistoryFormatException(
|
||||
'MET history sample count is invalid.',
|
||||
);
|
||||
}
|
||||
if (parts.length != count + 3) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
|
||||
);
|
||||
}
|
||||
|
||||
final values = <double>[];
|
||||
for (final part in parts.skip(3)) {
|
||||
final value = double.tryParse(part);
|
||||
if (value == null) {
|
||||
throw BTHomeMetHistoryFormatException(
|
||||
'Invalid MET history sample value: $part',
|
||||
);
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
return BTHomeMetHistoryPage(
|
||||
measurement: measurement,
|
||||
page: page,
|
||||
values: List<double>.unmodifiable(values),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTruthyCapabilityValue(Object? value) {
|
||||
if (value is num) {
|
||||
return value > 0;
|
||||
}
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _hasBTHomeMetCapability(Contact? contact) {
|
||||
final extraSensorData = contact?.telemetry?.extraSensorData;
|
||||
if (extraSensorData == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// MET history is only enabled when the node advertises an explicit
|
||||
// capability marker on channel 1. Accept a small set of channel-1 marker
|
||||
// keys so the app remains tolerant while firmware-side encoding settles.
|
||||
const capabilityKeys = <String>[
|
||||
'met_capability',
|
||||
'met_capability_1',
|
||||
'generic_sensor_1',
|
||||
'light_level_1',
|
||||
];
|
||||
|
||||
for (final key in capabilityKeys) {
|
||||
if (_isTruthyCapabilityValue(extraSensorData[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
||||
final telemetry = contact?.telemetry;
|
||||
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
|
||||
return const <BTHomeMetMeasurement>[];
|
||||
}
|
||||
|
||||
final measurements = <BTHomeMetMeasurement>[
|
||||
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
|
||||
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
|
||||
];
|
||||
|
||||
final extraSensorData = telemetry.extraSensorData;
|
||||
if (extraSensorData != null) {
|
||||
if (extraSensorData.keys.any(
|
||||
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
|
||||
)) {
|
||||
measurements.add(BTHomeMetMeasurement.windSpeed);
|
||||
}
|
||||
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
|
||||
measurements.add(BTHomeMetMeasurement.gust);
|
||||
}
|
||||
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
|
||||
measurements.add(BTHomeMetMeasurement.rain);
|
||||
}
|
||||
}
|
||||
|
||||
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
|
||||
}
|
||||
|
||||
bool supportsBTHomeMetHistory(Contact? contact) =>
|
||||
bTHomeMetMeasurementsForContact(contact).isNotEmpty;
|
||||
53
lib/services/build_info_service.dart
Normal file
53
lib/services/build_info_service.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Service for accessing build information from native platform code
|
||||
/// Currently supports Android only - returns "unknown" for other platforms
|
||||
class BuildInfoService {
|
||||
static final BuildInfoService _instance = BuildInfoService._internal();
|
||||
factory BuildInfoService() => _instance;
|
||||
BuildInfoService._internal();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('com.meshcore.sar/build_info');
|
||||
|
||||
String? _cachedCommitHash;
|
||||
|
||||
/// Get the commit hash that was embedded during build time
|
||||
/// Returns "unknown" if:
|
||||
/// - Not running on Android
|
||||
/// - Platform channel call fails
|
||||
/// - Build was not configured with COMMIT_HASH
|
||||
Future<String> getCommitHash() async {
|
||||
// Return cached value if available
|
||||
if (_cachedCommitHash != null) {
|
||||
return _cachedCommitHash!;
|
||||
}
|
||||
|
||||
// Only Android has the platform channel implementation
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
}
|
||||
|
||||
try {
|
||||
final String commitHash = await _channel.invokeMethod('getCommitHash');
|
||||
_cachedCommitHash = commitHash;
|
||||
debugPrint('[BuildInfoService] Commit hash: $commitHash');
|
||||
return commitHash;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('[BuildInfoService] Failed to get commit hash: ${e.message}');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
} catch (e) {
|
||||
debugPrint('[BuildInfoService] Unexpected error getting commit hash: $e');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cached commit hash (useful for testing)
|
||||
void clearCache() {
|
||||
_cachedCommitHash = null;
|
||||
}
|
||||
}
|
||||
942
lib/services/cayenne_lpp_parser.dart
Normal file
942
lib/services/cayenne_lpp_parser.dart
Normal file
@@ -0,0 +1,942 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
/// Cayenne LPP (Low Power Payload) data parser
|
||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||
class CayenneLppParser {
|
||||
static const int _selfTelemetryChannel = 1;
|
||||
static const int _lppGenericSensor = 100;
|
||||
static const int _lppCurrent = 117;
|
||||
static const int _lppFrequency = 118;
|
||||
static const int _lppPercentage = 120;
|
||||
static const int _lppAltitude = 121;
|
||||
static const int _lppConcentration = 125;
|
||||
static const int _lppPower = 128;
|
||||
static const int _lppSpeed = 129;
|
||||
static const int _lppDistance = 130;
|
||||
static const int _lppEnergy = 131;
|
||||
static const int _lppDirection = 132;
|
||||
static const int _lppUnixTime = 133;
|
||||
static const int _lppColour = 135;
|
||||
static const int _lppGust = 137;
|
||||
static const int _lppDewPoint = 138;
|
||||
static const int _lppRain = 139;
|
||||
static const int _lppSwitch = 142;
|
||||
static const int _lppBinaryBool = 143;
|
||||
static const int _lppBinaryPowerSwitch = 144;
|
||||
static const int _lppBinaryOpen = 145;
|
||||
static const int _lppBinaryBatteryLow = 146;
|
||||
static const int _lppBinaryCharging = 147;
|
||||
static const int _lppBinaryCarbonMonoxide = 148;
|
||||
static const int _lppBinaryCold = 149;
|
||||
static const int _lppBinaryConnectivity = 150;
|
||||
static const int _lppBinaryDoor = 151;
|
||||
static const int _lppBinaryGarageDoor = 152;
|
||||
static const int _lppBinaryGas = 153;
|
||||
static const int _lppBinaryHeat = 154;
|
||||
static const int _lppBinaryLight = 155;
|
||||
static const int _lppBinaryLock = 156;
|
||||
static const int _lppBinaryMoisture = 157;
|
||||
static const int _lppBinaryMotion = 158;
|
||||
static const int _lppBinaryMoving = 159;
|
||||
static const int _lppBinaryOccupancy = 160;
|
||||
static const int _lppBinaryPlug = 161;
|
||||
static const int _lppBinaryPresence = 162;
|
||||
static const int _lppBinaryProblem = 163;
|
||||
static const int _lppBinaryRunning = 164;
|
||||
static const int _lppBinarySafety = 165;
|
||||
static const int _lppBinarySmoke = 166;
|
||||
static const int _lppBinarySound = 167;
|
||||
static const int _lppBinaryTamper = 168;
|
||||
static const int _lppBinaryVibration = 169;
|
||||
static const int _lppBinaryWindow = 170;
|
||||
static const int _lppButtonEvent = 171;
|
||||
static const int _lppDimmer = 172;
|
||||
static const int _lppUv = 173;
|
||||
static const int _lppLightLevel = 174;
|
||||
static const int _lppPm25 = 175;
|
||||
static const int _lppPm10 = 176;
|
||||
static const int _lppCo2 = 177;
|
||||
static const int _lppTvoc = 178;
|
||||
static const int _lppRpm = 179;
|
||||
static const int _lppConductivity = 180;
|
||||
static const int _lppRotation = 181;
|
||||
static const int _lppDuration = 182;
|
||||
static const int _lppAcceleration = 183;
|
||||
static const int _lppGyroRate = 184;
|
||||
static const int _lppVolume = 185;
|
||||
static const int _lppFlowRate = 186;
|
||||
static const int _lppVolumeStorage = 187;
|
||||
static const int _lppWater = 188;
|
||||
static const int _lppGasVolume = 189;
|
||||
static const int _lppMass = 190;
|
||||
static const int _lppSignedSpeed = 191;
|
||||
static const int _lppSignedPower = 192;
|
||||
static const int _lppSignedCurrent = 193;
|
||||
|
||||
/// Parse Cayenne LPP data into ContactTelemetry
|
||||
static ContactTelemetry parse(Uint8List data) {
|
||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||
debugPrint(' Data length: ${data.length} bytes');
|
||||
debugPrint(
|
||||
' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
|
||||
);
|
||||
|
||||
final reader = BufferReader(data);
|
||||
|
||||
LatLng? gpsLocation;
|
||||
double? batteryPercentage;
|
||||
double? batteryMilliVolts;
|
||||
double? deferredBatteryMilliVolts;
|
||||
double? deferredBatteryPercentage;
|
||||
double? temperature;
|
||||
double? humidity;
|
||||
double? pressure;
|
||||
final extraSensorData = <String, dynamic>{};
|
||||
bool sawNonSelfChannel = false;
|
||||
|
||||
int fieldCount = 0;
|
||||
while (reader.hasRemaining) {
|
||||
if (fieldCount > 0 &&
|
||||
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||
debugPrint(
|
||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||
'${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
fieldCount++;
|
||||
debugPrint(
|
||||
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
|
||||
final channel = reader.readByte();
|
||||
debugPrint(' Channel: $channel');
|
||||
if (channel != _selfTelemetryChannel) {
|
||||
sawNonSelfChannel = true;
|
||||
}
|
||||
|
||||
final type = reader.readByte();
|
||||
debugPrint(
|
||||
' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})',
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Digital Input: $value');
|
||||
extraSensorData['digital_input_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppDigitalOutput:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Digital Output: $value');
|
||||
extraSensorData['digital_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Analog Input (raw): $rawValue');
|
||||
debugPrint(' Analog Input (volts): ${value}V');
|
||||
if (_isDedicatedBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else if (_isDeferredBatteryChannel(channel)) {
|
||||
deferredBatteryMilliVolts = value * 1000;
|
||||
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
} else {
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Analog Output (raw): $rawValue');
|
||||
debugPrint(' Analog Output (volts): ${value}V');
|
||||
extraSensorData['analog_output_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Illuminance: $value lux');
|
||||
extraSensorData['illuminance_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppPresenceSensor:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Presence: $value');
|
||||
extraSensorData['presence_$channel'] = value;
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Temperature (raw): $rawValue');
|
||||
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
temperature = value;
|
||||
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||
} else {
|
||||
extraSensorData['temperature_$channel'] = value;
|
||||
if (temperature == null) {
|
||||
temperature = value;
|
||||
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
final rawValue = reader.readByte();
|
||||
final value = rawValue / 2.0;
|
||||
debugPrint(' Humidity (raw): $rawValue');
|
||||
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
humidity = value;
|
||||
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||
} else {
|
||||
extraSensorData['humidity_$channel'] = value;
|
||||
if (humidity == null) {
|
||||
humidity = value;
|
||||
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
final x = reader.readInt16BE() / 1000.0;
|
||||
final y = reader.readInt16BE() / 1000.0;
|
||||
final z = reader.readInt16BE() / 1000.0;
|
||||
debugPrint(' Accelerometer: x=$x, y=$y, z=$z');
|
||||
extraSensorData['accelerometer_$channel'] = {
|
||||
'x': x,
|
||||
'y': y,
|
||||
'z': z,
|
||||
};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Barometer (raw): $rawValue');
|
||||
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
|
||||
if (channel == _selfTelemetryChannel) {
|
||||
pressure = value;
|
||||
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||
} else {
|
||||
extraSensorData['pressure_$channel'] = value;
|
||||
if (pressure == null) {
|
||||
pressure = value;
|
||||
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Voltage (raw): $rawValue');
|
||||
debugPrint(' Voltage: ${value}V');
|
||||
if (_isDedicatedBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else if (_isDeferredBatteryChannel(channel)) {
|
||||
deferredBatteryMilliVolts = value * 1000;
|
||||
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData['voltage_$channel'] = value;
|
||||
} else {
|
||||
extraSensorData['voltage_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
final x = reader.readInt16BE() / 100.0;
|
||||
final y = reader.readInt16BE() / 100.0;
|
||||
final z = reader.readInt16BE() / 100.0;
|
||||
debugPrint(' Gyrometer: x=$x, y=$y, z=$z');
|
||||
extraSensorData['gyrometer_$channel'] = {'x': x, 'y': y, 'z': z};
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGps:
|
||||
// Standard Cayenne LPP GPS format (type 0x88):
|
||||
// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||
// - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||
// - Altitude: 3 bytes, signed 24-bit, big-endian, × 100
|
||||
// Total: 9 bytes (not the 12 bytes used in MeshCore advertisements!)
|
||||
|
||||
// Read 3-byte signed big-endian integers
|
||||
final latBytes = reader.readBytes(3);
|
||||
int rawLat = (latBytes[0] << 16) | (latBytes[1] << 8) | latBytes[2];
|
||||
// Sign extend from 24-bit to 32-bit
|
||||
if (rawLat > 0x7FFFFF) rawLat = rawLat - 0x1000000;
|
||||
|
||||
final lonBytes = reader.readBytes(3);
|
||||
int rawLon = (lonBytes[0] << 16) | (lonBytes[1] << 8) | lonBytes[2];
|
||||
if (rawLon > 0x7FFFFF) rawLon = rawLon - 0x1000000;
|
||||
|
||||
final altBytes = reader.readBytes(3);
|
||||
int rawAlt = (altBytes[0] << 16) | (altBytes[1] << 8) | altBytes[2];
|
||||
if (rawAlt > 0x7FFFFF) rawAlt = rawAlt - 0x1000000;
|
||||
|
||||
// Decode: divide by scaling factors
|
||||
final lat = rawLat / 10000.0;
|
||||
final lon = rawLon / 10000.0;
|
||||
final alt = rawAlt / 100.0;
|
||||
|
||||
debugPrint(
|
||||
' GPS Location (raw 24-bit BE): lat=$rawLat (0x${rawLat.toRadixString(16).padLeft(6, '0')}), lon=$rawLon (0x${rawLon.toRadixString(16).padLeft(6, '0')}), alt=$rawAlt (0x${rawAlt.toRadixString(16).padLeft(6, '0')})',
|
||||
);
|
||||
debugPrint(
|
||||
' GPS Location (decoded): ${lat.toStringAsFixed(6)}°, ${lon.toStringAsFixed(6)}°, altitude=${alt.toStringAsFixed(2)}m',
|
||||
);
|
||||
|
||||
// Validate coordinates are in valid range
|
||||
if (lat < -90.0 || lat > 90.0) {
|
||||
debugPrint(' ⚠️ WARNING: Latitude out of range: $lat°');
|
||||
}
|
||||
if (lon < -180.0 || lon > 180.0) {
|
||||
debugPrint(' ⚠️ WARNING: Longitude out of range: $lon°');
|
||||
}
|
||||
|
||||
gpsLocation = LatLng(lat, lon);
|
||||
extraSensorData[_sourceChannelKey('gps')] = channel;
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
break;
|
||||
|
||||
case _lppGenericSensor:
|
||||
final value = _readUInt32BE(reader).toDouble();
|
||||
debugPrint(' Generic Sensor: $value');
|
||||
extraSensorData['generic_sensor_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppCurrent:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Current (raw): $rawValue');
|
||||
debugPrint(' Current: ${value}A');
|
||||
extraSensorData['current_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppFrequency:
|
||||
final value = _readUInt32BE(reader).toDouble();
|
||||
debugPrint(' Frequency: ${value}Hz');
|
||||
extraSensorData['frequency_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPercentage:
|
||||
final value = reader.readByte().toDouble();
|
||||
debugPrint(' Percentage: $value%');
|
||||
if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
|
||||
batteryPercentage = value;
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
} else {
|
||||
extraSensorData['percentage_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case _lppAltitude:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue.toDouble();
|
||||
debugPrint(' Altitude: ${value}m');
|
||||
extraSensorData['altitude_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppConcentration:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Concentration: ${value}ppm');
|
||||
extraSensorData['concentration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPower:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Power: ${value}W');
|
||||
extraSensorData['power_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSpeed:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Speed: ${value}m/s');
|
||||
extraSensorData['speed_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDistance:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Distance: ${value}m');
|
||||
extraSensorData['distance_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppEnergy:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Energy: ${value}kWh');
|
||||
extraSensorData['energy_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDirection:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Direction: $value°');
|
||||
extraSensorData['direction_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppUnixTime:
|
||||
final value = _readUInt32BE(reader);
|
||||
debugPrint(' Unix time: $value');
|
||||
extraSensorData['unixtime_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppColour:
|
||||
final red = reader.readByte();
|
||||
final green = reader.readByte();
|
||||
final blue = reader.readByte();
|
||||
debugPrint(' Colour: r=$red, g=$green, b=$blue');
|
||||
extraSensorData['colour_$channel'] = {
|
||||
'r': red,
|
||||
'g': green,
|
||||
'b': blue,
|
||||
};
|
||||
break;
|
||||
|
||||
case _lppGust:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Gust: ${value}m/s');
|
||||
extraSensorData['gust_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDewPoint:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
|
||||
extraSensorData['dew_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRain:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Rain: ${value}mm');
|
||||
extraSensorData['rain_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppBinaryBool:
|
||||
case _lppBinaryPowerSwitch:
|
||||
case _lppBinaryOpen:
|
||||
case _lppBinaryBatteryLow:
|
||||
case _lppBinaryCharging:
|
||||
case _lppBinaryCarbonMonoxide:
|
||||
case _lppBinaryCold:
|
||||
case _lppBinaryConnectivity:
|
||||
case _lppBinaryDoor:
|
||||
case _lppBinaryGarageDoor:
|
||||
case _lppBinaryGas:
|
||||
case _lppBinaryHeat:
|
||||
case _lppBinaryLight:
|
||||
case _lppBinaryLock:
|
||||
case _lppBinaryMoisture:
|
||||
case _lppBinaryMotion:
|
||||
case _lppBinaryMoving:
|
||||
case _lppBinaryOccupancy:
|
||||
case _lppBinaryPlug:
|
||||
case _lppBinaryPresence:
|
||||
case _lppBinaryProblem:
|
||||
case _lppBinaryRunning:
|
||||
case _lppBinarySafety:
|
||||
case _lppBinarySmoke:
|
||||
case _lppBinarySound:
|
||||
case _lppBinaryTamper:
|
||||
case _lppBinaryVibration:
|
||||
case _lppBinaryWindow:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Binary state: $value');
|
||||
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
|
||||
value;
|
||||
break;
|
||||
|
||||
case _lppButtonEvent:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Button event: $value');
|
||||
extraSensorData['button_event_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDimmer:
|
||||
final value = _readInt8(reader);
|
||||
debugPrint(' Dimmer: $value');
|
||||
extraSensorData['dimmer_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppUv:
|
||||
final value = reader.readByte() / 10.0;
|
||||
debugPrint(' UV index: $value');
|
||||
extraSensorData['uv_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppLightLevel:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Light level: $value');
|
||||
extraSensorData['light_level_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPm25:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' PM2.5: $value');
|
||||
extraSensorData['pm25_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppPm10:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' PM10: $value');
|
||||
extraSensorData['pm10_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppCo2:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' CO2: $value');
|
||||
extraSensorData['co2_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppTvoc:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' TVOC: $value');
|
||||
extraSensorData['tvoc_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRpm:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' RPM: $value');
|
||||
extraSensorData['rpm_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppConductivity:
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Conductivity: $value');
|
||||
extraSensorData['conductivity_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppRotation:
|
||||
final rawValue = reader.readInt16BE();
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Rotation: $value');
|
||||
extraSensorData['rotation_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppDuration:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Duration: $value s');
|
||||
extraSensorData['duration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppAcceleration:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000000.0;
|
||||
debugPrint(' Acceleration: $value');
|
||||
extraSensorData['acceleration_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppGyroRate:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Gyro rate: $value');
|
||||
extraSensorData['gyro_rate_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppVolume:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Volume: $value');
|
||||
extraSensorData['volume_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppFlowRate:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Flow rate: $value');
|
||||
extraSensorData['flow_rate_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppVolumeStorage:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Storage volume: $value');
|
||||
extraSensorData['volume_storage_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppWater:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Water: $value');
|
||||
extraSensorData['water_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppGasVolume:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Gas volume: $value');
|
||||
extraSensorData['gas_volume_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppMass:
|
||||
final rawValue = _readUInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Mass: $value');
|
||||
extraSensorData['mass_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedSpeed:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000000.0;
|
||||
debugPrint(' Signed speed: $value');
|
||||
extraSensorData['signed_speed_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedPower:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Signed power: $value');
|
||||
extraSensorData['signed_power_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSignedCurrent:
|
||||
final rawValue = _readInt32BE(reader);
|
||||
final value = rawValue / 1000.0;
|
||||
debugPrint(' Signed current: $value');
|
||||
extraSensorData['signed_current_$channel'] = value;
|
||||
break;
|
||||
|
||||
case _lppSwitch:
|
||||
final value = reader.readByte();
|
||||
debugPrint(' Switch: $value');
|
||||
extraSensorData['switch_$channel'] = value;
|
||||
break;
|
||||
|
||||
default:
|
||||
final size = _payloadSizeForType(type);
|
||||
if (size == null || reader.remainingBytesCount < size) {
|
||||
debugPrint(
|
||||
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||
);
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
}
|
||||
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||
reader.skip(size);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(' ❌ Parsing error: $e');
|
||||
// If we encounter a parsing error, break and return what we have
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batteryMilliVolts == null &&
|
||||
batteryPercentage == null &&
|
||||
deferredBatteryMilliVolts != null &&
|
||||
deferredBatteryPercentage != null &&
|
||||
!sawNonSelfChannel) {
|
||||
batteryMilliVolts = deferredBatteryMilliVolts;
|
||||
batteryPercentage = deferredBatteryPercentage;
|
||||
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
|
||||
debugPrint(
|
||||
' Promoted self-channel voltage to battery: '
|
||||
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(' Parsed $fieldCount fields');
|
||||
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
||||
debugPrint(
|
||||
' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}',
|
||||
);
|
||||
debugPrint(
|
||||
' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}',
|
||||
);
|
||||
debugPrint(
|
||||
' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}',
|
||||
);
|
||||
|
||||
// IMPORTANT: Cayenne LPP format does NOT include a timestamp field.
|
||||
// We use DateTime.now() as the timestamp, which represents when the data
|
||||
// was RECEIVED/PARSED by the app, NOT when it was collected by the device.
|
||||
//
|
||||
// This means:
|
||||
// - If the device sends cached/old telemetry data, the timestamp will still
|
||||
// show as "recent" (a few seconds ago) because it was just received
|
||||
// - The actual age of the telemetry data cannot be determined from the LPP format
|
||||
// - Devices may cache telemetry for hours and send it later when requested
|
||||
final parseTimestamp = DateTime.now();
|
||||
debugPrint(
|
||||
' Timestamp: $parseTimestamp (parse time, NOT device collection time)',
|
||||
);
|
||||
|
||||
return ContactTelemetry(
|
||||
gpsLocation: gpsLocation,
|
||||
batteryPercentage: batteryPercentage,
|
||||
batteryMilliVolts: batteryMilliVolts,
|
||||
temperature: temperature,
|
||||
humidity: humidity,
|
||||
pressure: pressure,
|
||||
timestamp: parseTimestamp,
|
||||
extraSensorData: extraSensorData.isNotEmpty ? extraSensorData : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate battery percentage from voltage (V)
|
||||
static double _calculateBatteryPercentage(double voltage) {
|
||||
// Standard lithium battery curve: 3.0V = 0%, 4.2V = 100%
|
||||
if (voltage <= 3.0) return 0.0;
|
||||
if (voltage >= 4.2) return 100.0;
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
|
||||
|
||||
static bool _isDeferredBatteryChannel(int channel) =>
|
||||
channel == _selfTelemetryChannel;
|
||||
|
||||
static int _readUInt32BE(BufferReader reader) {
|
||||
final bytes = reader.readBytes(4);
|
||||
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||
}
|
||||
|
||||
static int _readInt32BE(BufferReader reader) {
|
||||
final value = _readUInt32BE(reader);
|
||||
if ((value & 0x80000000) != 0) {
|
||||
return value - 0x100000000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static int _readInt8(BufferReader reader) {
|
||||
final value = reader.readByte();
|
||||
if ((value & 0x80) != 0) {
|
||||
return value - 0x100;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _sourceChannelKey(String fieldKey) =>
|
||||
'__source_channel:$fieldKey';
|
||||
|
||||
static String _binaryMetricKeyForType(int type) {
|
||||
switch (type) {
|
||||
case _lppBinaryBool:
|
||||
return 'binary_bool';
|
||||
case _lppBinaryPowerSwitch:
|
||||
return 'binary_power_switch';
|
||||
case _lppBinaryOpen:
|
||||
return 'binary_open';
|
||||
case _lppBinaryBatteryLow:
|
||||
return 'binary_battery_low';
|
||||
case _lppBinaryCharging:
|
||||
return 'binary_charging';
|
||||
case _lppBinaryCarbonMonoxide:
|
||||
return 'binary_carbon_monoxide';
|
||||
case _lppBinaryCold:
|
||||
return 'binary_cold';
|
||||
case _lppBinaryConnectivity:
|
||||
return 'binary_connectivity';
|
||||
case _lppBinaryDoor:
|
||||
return 'binary_door';
|
||||
case _lppBinaryGarageDoor:
|
||||
return 'binary_garage_door';
|
||||
case _lppBinaryGas:
|
||||
return 'binary_gas';
|
||||
case _lppBinaryHeat:
|
||||
return 'binary_heat';
|
||||
case _lppBinaryLight:
|
||||
return 'binary_light';
|
||||
case _lppBinaryLock:
|
||||
return 'binary_lock';
|
||||
case _lppBinaryMoisture:
|
||||
return 'binary_moisture';
|
||||
case _lppBinaryMotion:
|
||||
return 'binary_motion';
|
||||
case _lppBinaryMoving:
|
||||
return 'binary_moving';
|
||||
case _lppBinaryOccupancy:
|
||||
return 'binary_occupancy';
|
||||
case _lppBinaryPlug:
|
||||
return 'binary_plug';
|
||||
case _lppBinaryPresence:
|
||||
return 'binary_presence';
|
||||
case _lppBinaryProblem:
|
||||
return 'binary_problem';
|
||||
case _lppBinaryRunning:
|
||||
return 'binary_running';
|
||||
case _lppBinarySafety:
|
||||
return 'binary_safety';
|
||||
case _lppBinarySmoke:
|
||||
return 'binary_smoke';
|
||||
case _lppBinarySound:
|
||||
return 'binary_sound';
|
||||
case _lppBinaryTamper:
|
||||
return 'binary_tamper';
|
||||
case _lppBinaryVibration:
|
||||
return 'binary_vibration';
|
||||
case _lppBinaryWindow:
|
||||
return 'binary_window';
|
||||
}
|
||||
return 'binary_state';
|
||||
}
|
||||
|
||||
static int? _payloadSizeForType(int type) {
|
||||
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
|
||||
return 1;
|
||||
}
|
||||
switch (type) {
|
||||
case MeshCoreConstants.lppDigitalInput:
|
||||
case MeshCoreConstants.lppDigitalOutput:
|
||||
case MeshCoreConstants.lppPresenceSensor:
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
case _lppPercentage:
|
||||
case _lppSwitch:
|
||||
case _lppButtonEvent:
|
||||
case _lppDimmer:
|
||||
case _lppUv:
|
||||
case _lppLightLevel:
|
||||
return 1;
|
||||
case MeshCoreConstants.lppAnalogInput:
|
||||
case MeshCoreConstants.lppAnalogOutput:
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
case _lppCurrent:
|
||||
case _lppAltitude:
|
||||
case _lppConcentration:
|
||||
case _lppPower:
|
||||
case _lppSpeed:
|
||||
case _lppDirection:
|
||||
case _lppGust:
|
||||
case _lppDewPoint:
|
||||
case _lppRain:
|
||||
case _lppPm25:
|
||||
case _lppPm10:
|
||||
case _lppCo2:
|
||||
case _lppTvoc:
|
||||
case _lppRpm:
|
||||
case _lppConductivity:
|
||||
case _lppRotation:
|
||||
return 2;
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
return 6;
|
||||
case MeshCoreConstants.lppGps:
|
||||
return 9;
|
||||
case _lppGenericSensor:
|
||||
case _lppFrequency:
|
||||
case _lppDistance:
|
||||
case _lppEnergy:
|
||||
case _lppUnixTime:
|
||||
case _lppDuration:
|
||||
case _lppAcceleration:
|
||||
case _lppGyroRate:
|
||||
case _lppVolume:
|
||||
case _lppFlowRate:
|
||||
case _lppVolumeStorage:
|
||||
case _lppWater:
|
||||
case _lppGasVolume:
|
||||
case _lppMass:
|
||||
case _lppSignedSpeed:
|
||||
case _lppSignedPower:
|
||||
case _lppSignedCurrent:
|
||||
return 4;
|
||||
case _lppColour:
|
||||
return 3;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||
final start = data.length - remainingBytes;
|
||||
for (int i = start; i < data.length; i++) {
|
||||
if (data[i] != 0) return false;
|
||||
}
|
||||
return remainingBytes > 0;
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for GPS location
|
||||
/// Standard Cayenne LPP GPS format (type 0x88):
|
||||
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||
/// - Longitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||
/// - Altitude: 3 bytes, signed 24-bit, big-endian, × 100
|
||||
static Uint8List createGpsData({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
double altitude = 0.0,
|
||||
int channel = 0,
|
||||
}) {
|
||||
final buffer = <int>[];
|
||||
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppGps);
|
||||
|
||||
// Latitude (signed 24-bit BE, 3 bytes, 0.0001° precision)
|
||||
int lat = (latitude * 10000).round();
|
||||
// Handle negative values (two's complement for 24-bit)
|
||||
if (lat < 0) lat = lat + 0x1000000;
|
||||
buffer.add((lat >> 16) & 0xFF); // Byte 0 (MSB)
|
||||
buffer.add((lat >> 8) & 0xFF); // Byte 1
|
||||
buffer.add(lat & 0xFF); // Byte 2 (LSB)
|
||||
|
||||
// Longitude (signed 24-bit BE, 3 bytes, 0.0001° precision)
|
||||
int lon = (longitude * 10000).round();
|
||||
if (lon < 0) lon = lon + 0x1000000;
|
||||
buffer.add((lon >> 16) & 0xFF); // Byte 0 (MSB)
|
||||
buffer.add((lon >> 8) & 0xFF); // Byte 1
|
||||
buffer.add(lon & 0xFF); // Byte 2 (LSB)
|
||||
|
||||
// Altitude (signed 24-bit BE, 3 bytes, 0.01m precision)
|
||||
int alt = (altitude * 100).round();
|
||||
if (alt < 0) alt = alt + 0x1000000;
|
||||
buffer.add((alt >> 16) & 0xFF); // Byte 0 (MSB)
|
||||
buffer.add((alt >> 8) & 0xFF); // Byte 1
|
||||
buffer.add(alt & 0xFF); // Byte 2 (LSB)
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for temperature
|
||||
static Uint8List createTemperatureData(double celsius, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppTemperatureSensor);
|
||||
|
||||
final temp = (celsius * 10).round();
|
||||
buffer.add((temp >> 8) & 0xFF);
|
||||
buffer.add(temp & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for battery voltage
|
||||
static Uint8List createBatteryData(double voltage, {int channel = 0}) {
|
||||
final buffer = <int>[];
|
||||
buffer.add(channel);
|
||||
buffer.add(MeshCoreConstants.lppAnalogInput);
|
||||
|
||||
final volts = (voltage * 100).round();
|
||||
buffer.add((volts >> 8) & 0xFF);
|
||||
buffer.add(volts & 0xFF);
|
||||
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
}
|
||||
11
lib/services/compass_support.dart
Normal file
11
lib/services/compass_support.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
|
||||
class CompassSupport {
|
||||
const CompassSupport._();
|
||||
|
||||
static bool get isAvailable =>
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS);
|
||||
}
|
||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ResolvedContactRoutePlan {
|
||||
final List<String> tokens;
|
||||
final List<Contact> selectedContacts;
|
||||
final String summary;
|
||||
|
||||
const ResolvedContactRoutePlan({
|
||||
required this.tokens,
|
||||
required this.selectedContacts,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
String get canonicalText => tokens.join(',');
|
||||
}
|
||||
|
||||
class ContactRouteResolver {
|
||||
static const Distance _distance = Distance();
|
||||
|
||||
const ContactRouteResolver._();
|
||||
|
||||
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||
required LatLng senderLocation,
|
||||
required Contact recipient,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final recipientLocation = recipient.displayLocation;
|
||||
if (recipientLocation == null) return null;
|
||||
|
||||
final repeaters = availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.isRepeater &&
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex != recipient.publicKeyHex,
|
||||
)
|
||||
.toList();
|
||||
if (repeaters.isEmpty) return null;
|
||||
|
||||
final recipientLatLng = LatLng(
|
||||
recipientLocation.latitude,
|
||||
recipientLocation.longitude,
|
||||
);
|
||||
|
||||
final routedCandidates =
|
||||
repeaters
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
).compareTo(
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (routedCandidates.isNotEmpty) {
|
||||
final anchor = routedCandidates.first;
|
||||
final tokens = <String>[
|
||||
...anchor.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toUpperCase()),
|
||||
_tokenFor(anchor, hashSize),
|
||||
];
|
||||
final selectedContacts = _matchContactsForTokens(
|
||||
tokens,
|
||||
availableContacts: repeaters,
|
||||
);
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: _dedupeTokens(tokens),
|
||||
selectedContacts: selectedContacts,
|
||||
summary: 'Resolved via known repeater route',
|
||||
);
|
||||
}
|
||||
|
||||
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||
..sort((a, b) {
|
||||
final progressA = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
a.displayLocation!.latitude,
|
||||
a.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressB = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
b.displayLocation!.latitude,
|
||||
b.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressCompare = progressA.compareTo(progressB);
|
||||
if (progressCompare != 0) return progressCompare;
|
||||
return _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
).compareTo(
|
||||
_scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final selected = <Contact>[];
|
||||
var currentPoint = senderLocation;
|
||||
var currentDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
final maxCorridorDistance = math.max(
|
||||
1500.0,
|
||||
currentDistanceToRecipient * 0.22,
|
||||
);
|
||||
|
||||
for (final repeater in corridorRepeaters) {
|
||||
if (selected.length >= 4) break;
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final distanceToSegment = _distanceToSegmentMeters(
|
||||
repeaterPoint,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (distanceToSegment > maxCorridorDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final nextDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
repeaterPoint,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceFromCurrent = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
currentPoint,
|
||||
repeaterPoint,
|
||||
);
|
||||
if (distanceFromCurrent < 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.add(repeater);
|
||||
currentPoint = repeaterPoint;
|
||||
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||
|
||||
if (currentDistanceToRecipient < 2500) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||
selectedContacts: selected,
|
||||
summary: 'Resolved from repeater locations',
|
||||
);
|
||||
}
|
||||
|
||||
static List<Contact> _matchContactsForTokens(
|
||||
List<String> tokens, {
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
final matches = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = availableContacts
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
matches.add(match);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static List<String> _dedupeTokens(List<String> tokens) {
|
||||
final result = <String>[];
|
||||
for (final token in tokens) {
|
||||
if (result.isEmpty || result.last != token) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
return hex.length < length ? hex : hex.substring(0, length);
|
||||
}
|
||||
|
||||
static double _scoreRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
}) {
|
||||
final point = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final toRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
point,
|
||||
recipientLocation,
|
||||
);
|
||||
final corridor = _distanceToSegmentMeters(
|
||||
point,
|
||||
senderLocation,
|
||||
recipientLocation,
|
||||
);
|
||||
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||
}
|
||||
|
||||
static double _scoreKnownRouteRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final baseScore = _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLocation,
|
||||
repeater: repeater,
|
||||
);
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
|
||||
final chainContacts = repeater.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map(
|
||||
(token) => availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex.toUpperCase().startsWith(
|
||||
token.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.firstOrNull,
|
||||
)
|
||||
.whereType<Contact>()
|
||||
.toList();
|
||||
|
||||
final chainEnd = chainContacts.isNotEmpty
|
||||
? LatLng(
|
||||
chainContacts.last.displayLocation!.latitude,
|
||||
chainContacts.last.displayLocation!.longitude,
|
||||
)
|
||||
: senderLocation;
|
||||
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||
final senderGap = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
repeaterPoint,
|
||||
);
|
||||
|
||||
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||
}
|
||||
|
||||
static double _progressAlongSegment({
|
||||
required LatLng point,
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
}) {
|
||||
final dx = end.longitude - start.longitude;
|
||||
final dy = end.latitude - start.latitude;
|
||||
final lengthSquared = (dx * dx) + (dy * dy);
|
||||
if (lengthSquared == 0) return 0;
|
||||
return (((point.longitude - start.longitude) * dx) +
|
||||
((point.latitude - start.latitude) * dy)) /
|
||||
lengthSquared;
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return _distance.as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return _distance.as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
424
lib/services/contact_storage_service.dart
Normal file
424
lib/services/contact_storage_service.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting contacts to local storage
|
||||
class ContactStorageService {
|
||||
static const String _contactsKey = 'stored_contacts';
|
||||
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||
static const int _maxStoredPendingAdverts = 500;
|
||||
|
||||
String _key(String baseKey, {String? namespace}) {
|
||||
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||
}
|
||||
|
||||
/// Save contacts to persistent storage
|
||||
Future<void> saveContacts(List<Contact> contacts, {String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert contacts to JSON
|
||||
final jsonList = contacts
|
||||
.map((contact) => _contactToJson(contact))
|
||||
.toList();
|
||||
|
||||
// Limit to max stored contacts (keep most recent)
|
||||
final limitedList = jsonList.length > _maxStoredContacts
|
||||
? jsonList.sublist(jsonList.length - _maxStoredContacts)
|
||||
: jsonList;
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contacts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load contacts from persistent storage
|
||||
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
|
||||
Future<List<Contact>> loadContacts({
|
||||
Uint8List? excludePublicKey,
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
debugPrint('ℹ️ [ContactStorage] No stored contacts found');
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
final contacts = jsonList
|
||||
.map((json) => _contactFromJson(json as Map<String, dynamic>))
|
||||
.where((contact) => contact != null)
|
||||
.cast<Contact>()
|
||||
.toList();
|
||||
|
||||
// Filter out contacts with the excluded public key
|
||||
final filteredContacts = excludePublicKey != null
|
||||
? contacts.where((contact) {
|
||||
final matches = contact.publicKey.matches(excludePublicKey);
|
||||
if (matches) {
|
||||
debugPrint(
|
||||
'ℹ️ [ContactStorage] Excluding contact with matching public key: ${contact.advName}',
|
||||
);
|
||||
}
|
||||
return !matches;
|
||||
}).toList()
|
||||
: contacts;
|
||||
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage'
|
||||
'${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}',
|
||||
);
|
||||
return filteredContacts;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading contacts: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all stored contacts
|
||||
Future<void> clearContacts({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_contactsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveContactGroups(
|
||||
List<SavedContactGroup> groups, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = jsonEncode(
|
||||
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||
);
|
||||
await prefs.setString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||
.whereType<SavedContactGroup>()
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_contactGroupsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all contact groups');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> savePendingAdverts(
|
||||
List<Map<String, dynamic>> adverts, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||
: adverts;
|
||||
await prefs.setString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
jsonEncode(limitedList),
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadPendingAdverts({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearPendingAdverts({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorites ───────────────────────────────────────────────────────────────
|
||||
|
||||
static const String _favoritesKey = 'contact_favorites';
|
||||
|
||||
Future<void> saveFavorites(
|
||||
Set<String> publicKeyHexes, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
publicKeyHexes.toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving favorites: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> loadFavorites({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
);
|
||||
return list?.toSet() ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading favorites: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
|
||||
final sizeBytes = jsonString.length;
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
|
||||
return {
|
||||
'contactCount': jsonList.length,
|
||||
'storageSizeBytes': sizeBytes,
|
||||
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error getting storage stats: $e');
|
||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Contact to JSON
|
||||
Map<String, dynamic> _contactToJson(Contact contact) {
|
||||
return {
|
||||
'publicKey': base64Encode(contact.publicKey),
|
||||
'type': contact.type.value,
|
||||
'flags': contact.flags,
|
||||
'outPathLen': contact.outPathLen,
|
||||
'outPath': base64Encode(contact.outPath),
|
||||
'advName': contact.advName,
|
||||
'nameOverride': contact.nameOverride,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
'lastMod': contact.lastMod,
|
||||
'telemetry': contact.telemetry != null
|
||||
? _telemetryToJson(contact.telemetry!)
|
||||
: null,
|
||||
'advertHistory': contact.advertHistory
|
||||
.map(
|
||||
(point) => {
|
||||
'lat': point.location.latitude,
|
||||
'lon': point.location.longitude,
|
||||
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert JSON to Contact
|
||||
Contact? _contactFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(
|
||||
base64Decode(json['publicKey'] as String),
|
||||
),
|
||||
type: ContactType.fromValue(json['type'] as int),
|
||||
flags: json['flags'] as int,
|
||||
outPathLen: json['outPathLen'] as int,
|
||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||
advName: json['advName'] as String,
|
||||
nameOverride: json['nameOverride'] as String?,
|
||||
lastAdvert: json['lastAdvert'] as int,
|
||||
advLat: json['advLat'] as int,
|
||||
advLon: json['advLon'] as int,
|
||||
lastMod: json['lastMod'] as int,
|
||||
telemetry: json['telemetry'] != null
|
||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||
: null,
|
||||
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert ContactTelemetry to JSON
|
||||
Map<String, dynamic> _telemetryToJson(ContactTelemetry telemetry) {
|
||||
return {
|
||||
'gpsLocation': telemetry.gpsLocation != null
|
||||
? {
|
||||
'latitude': telemetry.gpsLocation!.latitude,
|
||||
'longitude': telemetry.gpsLocation!.longitude,
|
||||
}
|
||||
: null,
|
||||
'batteryPercentage': telemetry.batteryPercentage,
|
||||
'batteryMilliVolts': telemetry.batteryMilliVolts,
|
||||
'temperature': telemetry.temperature,
|
||||
'humidity': telemetry.humidity,
|
||||
'pressure': telemetry.pressure,
|
||||
'timestampMillis': telemetry.timestamp.millisecondsSinceEpoch,
|
||||
'extraSensorData': telemetry.extraSensorData,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert JSON to ContactTelemetry
|
||||
ContactTelemetry? _telemetryFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return ContactTelemetry(
|
||||
gpsLocation: json['gpsLocation'] != null
|
||||
? LatLng(
|
||||
json['gpsLocation']['latitude'] as double,
|
||||
json['gpsLocation']['longitude'] as double,
|
||||
)
|
||||
: null,
|
||||
batteryPercentage: json['batteryPercentage'] as double?,
|
||||
batteryMilliVolts: json['batteryMilliVolts'] as double?,
|
||||
temperature: json['temperature'] as double?,
|
||||
humidity: json['humidity'] as double?,
|
||||
pressure: json['pressure'] as double?,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['timestampMillis'] as int,
|
||||
),
|
||||
extraSensorData: json['extraSensorData'] as Map<String, dynamic>?,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing telemetry from JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||
if (json is! List) return [];
|
||||
final result = <AdvertLocation>[];
|
||||
for (final item in json) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final lat = item['lat'];
|
||||
final lon = item['lon'];
|
||||
final tsMillis = item['tsMillis'];
|
||||
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||
result.add(
|
||||
AdvertLocation(
|
||||
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||
return {
|
||||
'id': group.id,
|
||||
'sectionKey': group.sectionKey,
|
||||
'label': group.label,
|
||||
'query': group.query,
|
||||
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||
'matchPrefixes': group.matchPrefixes,
|
||||
'isAutoGroup': group.isAutoGroup,
|
||||
};
|
||||
}
|
||||
|
||||
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return SavedContactGroup(
|
||||
id: json['id'] as String,
|
||||
sectionKey: json['sectionKey'] as String,
|
||||
label: json['label'] as String,
|
||||
query: json['query'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['createdAtMillis'] as int,
|
||||
),
|
||||
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||
?.map((value) => value as String)
|
||||
.toList(),
|
||||
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/services/developer_mode_service.dart
Normal file
15
lib/services/developer_mode_service.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class DeveloperModeService {
|
||||
static const String _developerModeKey = 'developer_mode_enabled';
|
||||
|
||||
static Future<bool> isEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_developerModeKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_developerModeKey, enabled);
|
||||
}
|
||||
}
|
||||
140
lib/services/device_config_applicator.dart
Normal file
140
lib/services/device_config_applicator.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/channels_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
|
||||
class DeviceConfigApplicator {
|
||||
ConfigProfileSections capture({
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) {
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: DeviceConfigProfileSection(
|
||||
frequencyKhz: deviceInfo.radioFreq,
|
||||
bandwidth: deviceInfo.radioBw,
|
||||
spreadingFactor: deviceInfo.radioSf,
|
||||
codingRate: deviceInfo.radioCr,
|
||||
repeatEnabled: deviceInfo.clientRepeat,
|
||||
txPower: deviceInfo.txPower,
|
||||
telemetryModes: deviceInfo.telemetryModes,
|
||||
advertLocationPolicy: deviceInfo.advertLocPolicy,
|
||||
multiAcks: deviceInfo.multiAcks,
|
||||
manualAddContacts: deviceInfo.manualAddContacts,
|
||||
autoAddUsers: deviceInfo.autoAddUsers,
|
||||
autoAddRepeaters: deviceInfo.autoAddRepeaters,
|
||||
autoAddRoomServers: deviceInfo.autoAddRoomServers,
|
||||
autoAddSensors: deviceInfo.autoAddSensors,
|
||||
autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest,
|
||||
publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat),
|
||||
publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon),
|
||||
),
|
||||
channels: channelsProvider.channels,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
ConfigProfile profile, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceConfig = profile.sections.deviceConfig;
|
||||
if (deviceConfig != null && !deviceConfig.isEmpty) {
|
||||
if (deviceConfig.txPower != null) {
|
||||
await connectionProvider.setTxPower(deviceConfig.txPower!);
|
||||
}
|
||||
if (deviceConfig.telemetryModes != null ||
|
||||
deviceConfig.advertLocationPolicy != null ||
|
||||
deviceConfig.manualAddContacts != null ||
|
||||
deviceConfig.multiAcks != null) {
|
||||
await connectionProvider.setOtherParams(
|
||||
manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0,
|
||||
telemetryModes: deviceConfig.telemetryModes ?? 0,
|
||||
advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0,
|
||||
multiAcks: deviceConfig.multiAcks ?? 0,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.autoAddUsers != null &&
|
||||
deviceConfig.autoAddRepeaters != null &&
|
||||
deviceConfig.autoAddRoomServers != null &&
|
||||
deviceConfig.autoAddSensors != null &&
|
||||
deviceConfig.autoAddOverwriteOldest != null) {
|
||||
await connectionProvider.setAutoaddConfig(
|
||||
autoAddUsers: deviceConfig.autoAddUsers!,
|
||||
autoAddRepeaters: deviceConfig.autoAddRepeaters!,
|
||||
autoAddRoomServers: deviceConfig.autoAddRoomServers!,
|
||||
autoAddSensors: deviceConfig.autoAddSensors!,
|
||||
overwriteOldest: deviceConfig.autoAddOverwriteOldest!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.publicLatitude != null &&
|
||||
deviceConfig.publicLongitude != null) {
|
||||
await connectionProvider.setAdvertLatLon(
|
||||
latitude: deviceConfig.publicLatitude!,
|
||||
longitude: deviceConfig.publicLongitude!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.frequencyKhz != null &&
|
||||
deviceConfig.bandwidth != null &&
|
||||
deviceConfig.spreadingFactor != null &&
|
||||
deviceConfig.codingRate != null) {
|
||||
await connectionProvider.setRadioParams(
|
||||
frequency: deviceConfig.frequencyKhz!,
|
||||
bandwidth: deviceConfig.bandwidth!,
|
||||
spreadingFactor: deviceConfig.spreadingFactor!,
|
||||
codingRate: deviceConfig.codingRate!,
|
||||
repeat: deviceConfig.repeatEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _applyChannels(
|
||||
channels: profile.sections.channels,
|
||||
connectionProvider: connectionProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
);
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _applyChannels({
|
||||
required List<Channel> channels,
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (channels.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final desired = {for (final channel in channels) channel.index: channel};
|
||||
|
||||
for (final channel in channels) {
|
||||
await connectionProvider.setChannelSlot(
|
||||
channelIdx: channel.index,
|
||||
channelName: channel.name,
|
||||
secret: Uint8List.fromList(channel.secret),
|
||||
);
|
||||
channelsProvider.addOrUpdateChannelObject(channel);
|
||||
}
|
||||
|
||||
for (final existing in channelsProvider.channels) {
|
||||
if (existing.index == 0 || desired.containsKey(existing.index)) {
|
||||
continue;
|
||||
}
|
||||
await connectionProvider.deleteChannel(existing.index);
|
||||
channelsProvider.removeChannel(existing.index);
|
||||
}
|
||||
}
|
||||
|
||||
double? _decodeAdvertCoordinate(int? value) {
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return value / 1e6;
|
||||
}
|
||||
}
|
||||
341
lib/services/gpx_service.dart
Normal file
341
lib/services/gpx_service.dart
Normal file
@@ -0,0 +1,341 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../models/location_trail.dart';
|
||||
|
||||
/// Service for importing and exporting location trails in GPX format
|
||||
class GpxService {
|
||||
/// Export a LocationTrail to GPX 1.1 format
|
||||
/// Returns the GPX content as a string
|
||||
static String exportToGpx(LocationTrail trail, {String? customName}) {
|
||||
final builder = XmlBuilder();
|
||||
|
||||
builder.processing('xml', 'version="1.0" encoding="UTF-8"');
|
||||
builder.element('gpx', nest: () {
|
||||
// GPX attributes
|
||||
builder.attribute('version', '1.1');
|
||||
builder.attribute('creator', 'MeshCore SAR');
|
||||
builder.attribute(
|
||||
'xmlns',
|
||||
'http://www.topografix.com/GPX/1/1',
|
||||
);
|
||||
builder.attribute(
|
||||
'xmlns:xsi',
|
||||
'http://www.w3.org/2001/XMLSchema-instance',
|
||||
);
|
||||
builder.attribute(
|
||||
'xsi:schemaLocation',
|
||||
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd',
|
||||
);
|
||||
|
||||
// Metadata section
|
||||
builder.element('metadata', nest: () {
|
||||
final name = customName ??
|
||||
'MeshCore Trail - ${_formatDateTime(trail.startTime)}';
|
||||
builder.element('name', nest: () => builder.text(name));
|
||||
builder.element(
|
||||
'time',
|
||||
nest: () => builder.text(trail.startTime.toIso8601String()),
|
||||
);
|
||||
|
||||
// Add trail statistics in description
|
||||
final distance = trail.totalDistance;
|
||||
final duration = trail.duration;
|
||||
final description =
|
||||
'Distance: ${_formatDistance(distance)}, '
|
||||
'Duration: ${_formatDuration(duration)}, '
|
||||
'Points: ${trail.points.length}';
|
||||
builder.element('desc', nest: () => builder.text(description));
|
||||
});
|
||||
|
||||
// Track section
|
||||
builder.element('trk', nest: () {
|
||||
final trackName = customName ?? 'MeshCore Trail';
|
||||
builder.element('name', nest: () => builder.text(trackName));
|
||||
|
||||
// Track segment with all points
|
||||
builder.element('trkseg', nest: () {
|
||||
for (final point in trail.points) {
|
||||
builder.element('trkpt', nest: () {
|
||||
builder.attribute('lat', point.position.latitude.toString());
|
||||
builder.attribute('lon', point.position.longitude.toString());
|
||||
|
||||
// Timestamp (required for proper GPX)
|
||||
builder.element(
|
||||
'time',
|
||||
nest: () => builder.text(point.timestamp.toIso8601String()),
|
||||
);
|
||||
|
||||
// Elevation (optional, set to 0 if not available)
|
||||
builder.element('ele', nest: () => builder.text('0'));
|
||||
|
||||
// Extensions for additional data (accuracy, speed)
|
||||
if (point.accuracy != null || point.speed != null) {
|
||||
builder.element('extensions', nest: () {
|
||||
if (point.accuracy != null) {
|
||||
builder.element(
|
||||
'accuracy',
|
||||
nest: () => builder.text(point.accuracy.toString()),
|
||||
);
|
||||
}
|
||||
if (point.speed != null) {
|
||||
builder.element(
|
||||
'speed',
|
||||
nest: () => builder.text(point.speed.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
final document = builder.buildDocument();
|
||||
return document.toXmlString(pretty: true, indent: ' ');
|
||||
}
|
||||
|
||||
/// Parse GPX content and return a LocationTrail
|
||||
/// Throws FormatException if GPX is invalid
|
||||
static LocationTrail importFromGpx(String gpxContent) {
|
||||
try {
|
||||
final document = XmlDocument.parse(gpxContent);
|
||||
final gpxElement = document.findElements('gpx').firstOrNull;
|
||||
|
||||
if (gpxElement == null) {
|
||||
throw const FormatException('Invalid GPX file: Missing <gpx> element');
|
||||
}
|
||||
|
||||
// Extract track name from metadata or track element (currently unused, kept for future use)
|
||||
// String? trackName;
|
||||
// final metadataName =
|
||||
// gpxElement.findElements('metadata').firstOrNull?.findElements('name').firstOrNull?.innerText;
|
||||
// final trackNameElement = gpxElement
|
||||
// .findElements('trk')
|
||||
// .firstOrNull
|
||||
// ?.findElements('name')
|
||||
// .firstOrNull
|
||||
// ?.innerText;
|
||||
// trackName = metadataName ?? trackNameElement ?? 'Imported Trail';
|
||||
|
||||
// Extract track points
|
||||
final trackPoints = <TrailPoint>[];
|
||||
final tracks = gpxElement.findElements('trk');
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid GPX file: No <trk> elements found',
|
||||
);
|
||||
}
|
||||
|
||||
// Process first track only
|
||||
final track = tracks.first;
|
||||
final segments = track.findElements('trkseg');
|
||||
|
||||
for (final segment in segments) {
|
||||
final trkpts = segment.findElements('trkpt');
|
||||
|
||||
for (final trkpt in trkpts) {
|
||||
try {
|
||||
// Extract latitude and longitude (required)
|
||||
final latStr = trkpt.getAttribute('lat');
|
||||
final lonStr = trkpt.getAttribute('lon');
|
||||
|
||||
if (latStr == null || lonStr == null) {
|
||||
debugPrint('⚠️ Skipping track point: Missing lat/lon attributes');
|
||||
continue;
|
||||
}
|
||||
|
||||
final lat = double.parse(latStr);
|
||||
final lon = double.parse(lonStr);
|
||||
|
||||
// Extract timestamp (optional)
|
||||
final timeStr =
|
||||
trkpt.findElements('time').firstOrNull?.innerText;
|
||||
final timestamp = timeStr != null
|
||||
? DateTime.parse(timeStr)
|
||||
: DateTime.now();
|
||||
|
||||
// Extract elevation (optional, currently unused but parsed for future use)
|
||||
// final eleStr = trkpt.findElements('ele').firstOrNull?.innerText;
|
||||
// final elevation = eleStr != null ? double.tryParse(eleStr) : null;
|
||||
|
||||
// Extract extensions (accuracy, speed)
|
||||
double? accuracy;
|
||||
double? speed;
|
||||
final extensions =
|
||||
trkpt.findElements('extensions').firstOrNull;
|
||||
if (extensions != null) {
|
||||
final accuracyStr =
|
||||
extensions.findElements('accuracy').firstOrNull?.innerText;
|
||||
final speedStr =
|
||||
extensions.findElements('speed').firstOrNull?.innerText;
|
||||
accuracy = accuracyStr != null ? double.tryParse(accuracyStr) : null;
|
||||
speed = speedStr != null ? double.tryParse(speedStr) : null;
|
||||
}
|
||||
|
||||
// Create trail point
|
||||
trackPoints.add(
|
||||
TrailPoint(
|
||||
position: LatLng(lat, lon),
|
||||
timestamp: timestamp,
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Error parsing track point: $e');
|
||||
// Continue with next point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (trackPoints.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid GPX file: No valid track points found',
|
||||
);
|
||||
}
|
||||
|
||||
// Create LocationTrail from parsed points
|
||||
final startTime = trackPoints.first.timestamp;
|
||||
final endTime = trackPoints.last.timestamp;
|
||||
|
||||
return LocationTrail(
|
||||
id: 'imported_${DateTime.now().millisecondsSinceEpoch}',
|
||||
points: trackPoints,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
isActive: false,
|
||||
);
|
||||
} on XmlException catch (e) {
|
||||
throw FormatException('Invalid GPX XML: ${e.message}');
|
||||
} catch (e) {
|
||||
throw FormatException('Failed to parse GPX file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Export trail to file and trigger system share sheet
|
||||
/// Returns true if successful
|
||||
static Future<bool> exportTrailToFile(
|
||||
LocationTrail trail, {
|
||||
String? customName,
|
||||
}) async {
|
||||
try {
|
||||
// Generate GPX content
|
||||
debugPrint('📤 Generating GPX content...');
|
||||
final gpxContent = exportToGpx(trail, customName: customName);
|
||||
|
||||
// Create filename with timestamp
|
||||
final timestamp = DateTime.now();
|
||||
final filename =
|
||||
'meshcore_trail_${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}_${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.gpx';
|
||||
|
||||
// Save to temporary directory
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/$filename');
|
||||
await file.writeAsString(gpxContent);
|
||||
|
||||
debugPrint('📤 GPX file saved: ${file.path}');
|
||||
debugPrint('📤 File size: ${file.lengthSync()} bytes');
|
||||
|
||||
// Share the file using system share sheet
|
||||
final result = await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(file.path, mimeType: 'application/gpx+xml')],
|
||||
subject: 'MeshCore Trail Export',
|
||||
text: 'MeshCore SAR location trail (${trail.points.length} points)',
|
||||
),
|
||||
);
|
||||
|
||||
debugPrint('📤 Share result: ${result.status}');
|
||||
return result.status == ShareResultStatus.success ||
|
||||
result.status == ShareResultStatus.unavailable; // unavailable = user dismissed, still OK
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to export trail: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import trail from GPX file using file picker
|
||||
/// Returns LocationTrail if successful, null if cancelled or failed
|
||||
static Future<LocationTrail?> importTrailFromFile() async {
|
||||
try {
|
||||
// Open file picker for GPX files
|
||||
debugPrint('📥 Opening file picker for GPX import...');
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['gpx'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
debugPrint('📥 Import cancelled by user');
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
debugPrint('📥 Selected file: ${file.name}');
|
||||
debugPrint('📥 File size: ${file.size} bytes');
|
||||
|
||||
// Read file content
|
||||
String gpxContent;
|
||||
if (file.path != null) {
|
||||
// File has path (mobile)
|
||||
gpxContent = await File(file.path!).readAsString();
|
||||
} else if (file.bytes != null) {
|
||||
// File has bytes (web)
|
||||
gpxContent = String.fromCharCodes(file.bytes!);
|
||||
} else {
|
||||
throw Exception('Unable to read file content');
|
||||
}
|
||||
|
||||
// Parse GPX content
|
||||
debugPrint('📥 Parsing GPX content...');
|
||||
final trail = importFromGpx(gpxContent);
|
||||
debugPrint(
|
||||
'✅ Successfully imported trail: ${trail.points.length} points',
|
||||
);
|
||||
|
||||
return trail;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to import trail: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format distance for display
|
||||
static String _formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format duration for display
|
||||
static String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${seconds}s';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format DateTime for filename
|
||||
static String _formatDateTime(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
147
lib/services/image_codec_service.dart
Normal file
147
lib/services/image_codec_service.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
|
||||
/// Compresses and resizes an image for low-bandwidth mesh transmission.
|
||||
///
|
||||
/// Target: ≤256×256 pixels, grayscale AVIF at aggressive quality.
|
||||
/// A typical 256×256 grayscale AVIF at quality 90 is highly compressed.
|
||||
/// → 7–20 fragments at 152 bytes each.
|
||||
class ImageCodecService {
|
||||
static const int _normalAvifSpeed = 6;
|
||||
static const int _ultraAvifSpeed = 4;
|
||||
|
||||
/// Compress [rawBytes] (any decodable format: JPEG/PNG/WebP/AVIF) to a
|
||||
/// small grayscale AVIF suitable for mesh transmission.
|
||||
///
|
||||
/// [maxDimension] caps width and height (default 256); aspect ratio is
|
||||
/// preserved and images smaller than the cap are not upscaled.
|
||||
/// [compression] 0 = lossless, 100 = smallest/worst (libavif CQ scale).
|
||||
///
|
||||
/// Returns `(bytes, width, height)` or null if decoding or encoding fails.
|
||||
static Future<({Uint8List bytes, int width, int height})?> compress(
|
||||
Uint8List rawBytes, {
|
||||
int maxDimension = 256,
|
||||
int compression = 90,
|
||||
bool grayscale = true,
|
||||
bool ultraMode = false,
|
||||
}) async {
|
||||
try {
|
||||
final effectiveMaxDimension = maxDimension.clamp(32, 1024);
|
||||
final effectiveCompression = ultraMode
|
||||
? (compression + 12).clamp(10, 100)
|
||||
: compression.clamp(10, 100);
|
||||
final forceGrayscale = ultraMode ? true : grayscale;
|
||||
|
||||
// 1a. Probe original dimensions (no resize).
|
||||
final probeCodec = await ui.instantiateImageCodec(rawBytes);
|
||||
final probeFrame = await probeCodec.getNextFrame();
|
||||
final srcW = probeFrame.image.width;
|
||||
final srcH = probeFrame.image.height;
|
||||
probeFrame.image.dispose();
|
||||
|
||||
// 1b. Compute contain dimensions: scale down only the limiting axis so
|
||||
// the image fits within maxDimension×maxDimension without stretching.
|
||||
int dstW = srcW;
|
||||
int dstH = srcH;
|
||||
if (srcW > effectiveMaxDimension || srcH > effectiveMaxDimension) {
|
||||
if (srcW >= srcH) {
|
||||
dstW = effectiveMaxDimension;
|
||||
dstH = (srcH * effectiveMaxDimension / srcW).round().clamp(
|
||||
1,
|
||||
effectiveMaxDimension,
|
||||
);
|
||||
} else {
|
||||
dstH = effectiveMaxDimension;
|
||||
dstW = (srcW * effectiveMaxDimension / srcH).round().clamp(
|
||||
1,
|
||||
effectiveMaxDimension,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1c. Decode at the exact contain size (single axis constrained).
|
||||
final codec = await ui.instantiateImageCodec(
|
||||
rawBytes,
|
||||
targetWidth: dstW,
|
||||
targetHeight: dstH,
|
||||
allowUpscaling: false,
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
|
||||
final w = image.width;
|
||||
final h = image.height;
|
||||
|
||||
// 2. Export RGBA pixels.
|
||||
final byteData = await image.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
);
|
||||
image.dispose();
|
||||
if (byteData == null) return null;
|
||||
|
||||
// 3. Optionally convert to grayscale in-place (luminance).
|
||||
final rgba = byteData.buffer.asUint8List();
|
||||
if (forceGrayscale) {
|
||||
for (var i = 0; i < rgba.length; i += 4) {
|
||||
final lum =
|
||||
(0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
|
||||
.round()
|
||||
.clamp(0, 255);
|
||||
rgba[i] = lum;
|
||||
rgba[i + 1] = lum;
|
||||
rgba[i + 2] = lum;
|
||||
rgba[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Re-encode grayscale RGBA → PNG so encodeAvif can decode it.
|
||||
// encodeAvif() takes an encoded image (PNG/JPEG), not raw RGBA.
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(rgba);
|
||||
final descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: w,
|
||||
height: h,
|
||||
pixelFormat: ui.PixelFormat.rgba8888,
|
||||
);
|
||||
final greyCodec = await descriptor.instantiateCodec();
|
||||
final greyFrame = await greyCodec.getNextFrame();
|
||||
final greyImage = greyFrame.image;
|
||||
final pngData = await greyImage.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
greyImage.dispose();
|
||||
if (pngData == null) return null;
|
||||
final pngBytes = pngData.buffer.asUint8List();
|
||||
|
||||
// 5. Encode PNG → AVIF.
|
||||
// maxQuantizer/minQuantizer: libavif CQ scale (0 = lossless, 63 = worst).
|
||||
// compression=90 maps to maxQuantizer≈57, minQuantizer≈37.
|
||||
final maxQ = ((effectiveCompression / 100) * 63).round().clamp(0, 63);
|
||||
final minQ = (maxQ * 0.65).round().clamp(0, maxQ);
|
||||
final avif = await encodeAvif(
|
||||
pngBytes,
|
||||
maxThreads: 2,
|
||||
maxQuantizer: maxQ,
|
||||
minQuantizer: minQ,
|
||||
// We force fully opaque alpha, so alpha can be quantized aggressively.
|
||||
maxQuantizerAlpha: 63,
|
||||
minQuantizerAlpha: 63,
|
||||
// Slower speed improves compression efficiency at similar quality.
|
||||
speed: ultraMode ? _ultraAvifSpeed : _normalAvifSpeed,
|
||||
keepExif: false,
|
||||
);
|
||||
if (avif.isEmpty) return null;
|
||||
|
||||
debugPrint(
|
||||
'📷 [ImageCodec] ${rawBytes.length}B → $w×$h grayscale AVIF '
|
||||
'${avif.length}B (${(avif.length * 100 / rawBytes.length).round()}%)',
|
||||
);
|
||||
return (bytes: avif, width: w, height: h);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [ImageCodec] compress error: $e\n$st');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
lib/services/image_preferences.dart
Normal file
76
lib/services/image_preferences.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Stores user-selected image compression settings.
|
||||
class ImagePreferences {
|
||||
static const String _maxSizeKey = 'image_max_size';
|
||||
// Keep the legacy key name so existing users retain their saved value.
|
||||
static const String _qualityKey = 'image_quality';
|
||||
static const String _grayscaleKey = 'image_grayscale';
|
||||
static const String _ultraModeKey = 'image_ultra_mode';
|
||||
|
||||
static const int defaultMaxSize = 256;
|
||||
static const int defaultQuality = 90;
|
||||
static const bool defaultGrayscale = true;
|
||||
static const bool defaultUltraMode = false;
|
||||
|
||||
static const List<int> supportedSizes = [64, 96, 128, 256];
|
||||
|
||||
static Future<int> getMaxSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ??
|
||||
defaultMaxSize;
|
||||
return supportedSizes.contains(value) ? value : defaultMaxSize;
|
||||
}
|
||||
|
||||
static Future<void> setMaxSize(int size) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size);
|
||||
}
|
||||
|
||||
static Future<int> getCompression() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ??
|
||||
defaultQuality;
|
||||
return value.clamp(10, 90);
|
||||
}
|
||||
|
||||
static Future<void> setCompression(int compression) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(
|
||||
ProfileStorageScope.scopedKey(_qualityKey),
|
||||
compression.clamp(10, 90),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getGrayscale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ??
|
||||
defaultGrayscale;
|
||||
}
|
||||
|
||||
static Future<void> setGrayscale(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value);
|
||||
}
|
||||
|
||||
static Future<bool> getUltraMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ??
|
||||
defaultUltraMode;
|
||||
}
|
||||
|
||||
static Future<void> setUltraMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value);
|
||||
}
|
||||
|
||||
static int effectiveMaxSize(
|
||||
int configuredMaxSize, {
|
||||
required bool ultraMode,
|
||||
}) {
|
||||
return configuredMaxSize.clamp(32, 1024);
|
||||
}
|
||||
}
|
||||
359
lib/services/live_traffic_summary.dart
Normal file
359
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,359 @@
|
||||
import '../models/ble_packet_log.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
enum LiveTrafficBusyness { quiet, active, busy }
|
||||
|
||||
class LiveTrafficPacketTypeDetails {
|
||||
final int payloadType;
|
||||
final String title;
|
||||
final String label;
|
||||
final String summary;
|
||||
final String description;
|
||||
|
||||
const LiveTrafficPacketTypeDetails({
|
||||
required this.payloadType,
|
||||
required this.title,
|
||||
required this.label,
|
||||
required this.summary,
|
||||
required this.description,
|
||||
});
|
||||
}
|
||||
|
||||
class LiveTrafficEntry {
|
||||
final BlePacketLog log;
|
||||
final DecodedLogRxRoute? route;
|
||||
|
||||
const LiveTrafficEntry({required this.log, required this.route});
|
||||
|
||||
static const List<LiveTrafficPacketTypeDetails> _knownPayloadTypes = [
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x00,
|
||||
title: 'FLOOD REQUEST',
|
||||
label: 'Request',
|
||||
summary: 'Encrypted request to a known peer',
|
||||
description:
|
||||
'Encrypted request to a known peer. The wire payload carries destination and source hashes plus a MAC, and the decrypted body starts with a timestamp followed by application-defined request data such as stats or keepalive requests.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x01,
|
||||
title: 'FLOOD RESPONSE',
|
||||
label: 'Response',
|
||||
summary: 'Encrypted reply to a request',
|
||||
description:
|
||||
'Encrypted reply to a Request or Anonymous request. After decryption, the body is application-defined response data with no single generic response envelope.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x02,
|
||||
title: 'FLOOD TEXT',
|
||||
label: 'Text message',
|
||||
summary: 'Encrypted direct text with timestamp and retry flags',
|
||||
description:
|
||||
'Encrypted direct text message to a known peer. The decrypted body contains a timestamp, a flags and attempt byte, and the message text.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x03,
|
||||
title: 'FLOOD ACK',
|
||||
label: 'Ack',
|
||||
summary: '4-byte acknowledgement for an earlier message',
|
||||
description:
|
||||
'Short acknowledgement proving that a prior message was received. It carries a 4-byte checksum derived from the original message data.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x04,
|
||||
title: 'FLOOD ADVERTISEMENT',
|
||||
label: 'Advertisement',
|
||||
summary: 'Signed node identity broadcast',
|
||||
description:
|
||||
'Signed node advertisement announcing a device identity plus app data such as a name or location. Receivers verify the signature before accepting it.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x05,
|
||||
title: 'FLOOD GROUP_TEXT',
|
||||
label: 'Group text',
|
||||
summary: 'Encrypted channel text matched by channel hash',
|
||||
description:
|
||||
'Encrypted channel text message. It is matched by the first byte of SHA256(channel secret), then decrypted with the channel key. The plaintext is usually in the form "<sender name>: <message body>".',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x06,
|
||||
title: 'FLOOD GROUP_DATA',
|
||||
label: 'Group datagram',
|
||||
summary: 'Encrypted channel data with type and length',
|
||||
description:
|
||||
'Encrypted channel datagram. After channel-hash matching and decryption, the body starts with a 16-bit data type and a 1-byte data length before the application payload.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x07,
|
||||
title: 'FLOOD ANON_REQUEST',
|
||||
label: 'Anonymous request',
|
||||
summary: 'Request using an ephemeral sender key',
|
||||
description:
|
||||
'Encrypted request to a destination hash without using a stored sender identity. The packet includes the sender\'s ephemeral public key so the receiver can derive the shared secret.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x08,
|
||||
title: 'FLOOD RETURNED_PATH',
|
||||
label: 'Returned path',
|
||||
summary:
|
||||
'Return route back to the sender, with optional bundled ACK or response',
|
||||
description:
|
||||
'Path reply sent back to the original author to describe the route a received packet took. MeshCore stores that returned path as the peer\'s direct out-path and can bundle an ACK or response in the same payload.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x09,
|
||||
title: 'FLOOD TRACE_PATH',
|
||||
label: 'Trace path',
|
||||
summary: 'Direct trace that records SNR at each hop',
|
||||
description:
|
||||
'Direct diagnostic packet that walks a supplied path and appends one SNR sample per hop. When it reaches the end of the path, the initiator can inspect hop-by-hop link quality.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x0A,
|
||||
title: 'FLOOD MULTIPART',
|
||||
label: 'Multipart packet',
|
||||
summary: 'Wrapper for one packet in a multipart sequence',
|
||||
description:
|
||||
'Packet wrapper used when a logical message is split into a sequence. Current MeshCore code uses it for multipart ACKs, where the first nibble says how many parts remain.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x0B,
|
||||
title: 'FLOOD CONTROL',
|
||||
label: 'Control packet',
|
||||
summary: 'Discovery or other control data',
|
||||
description:
|
||||
'Control or discovery payload, typically unencrypted. Current documented subtypes are discovery request and response packets used to find nearby nodes and report SNR.',
|
||||
),
|
||||
LiveTrafficPacketTypeDetails(
|
||||
payloadType: 0x0F,
|
||||
title: 'RAW CUSTOM',
|
||||
label: 'Custom packet',
|
||||
summary: 'Application-defined custom packet',
|
||||
description:
|
||||
'Application-defined raw packet bytes for custom encryption or custom payload formats. MeshCore leaves the inner format up to the higher-level application.',
|
||||
),
|
||||
];
|
||||
|
||||
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||
|
||||
int? get hopCount => route?.hopCount;
|
||||
|
||||
String get payloadLabel {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null) {
|
||||
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||
}
|
||||
return payloadTypeLabel(decodedRoute.payloadType);
|
||||
}
|
||||
|
||||
String? get payloadMeaning {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null) return null;
|
||||
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||
}
|
||||
|
||||
String get routePreview {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||
return 'Direct packet';
|
||||
}
|
||||
return decodedRoute.hopHashes
|
||||
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||
.join(' -> ');
|
||||
}
|
||||
|
||||
static List<LiveTrafficPacketTypeDetails> get knownPayloadTypes =>
|
||||
_knownPayloadTypes;
|
||||
|
||||
static LiveTrafficPacketTypeDetails payloadTypeDetails(int payloadType) {
|
||||
for (final details in _knownPayloadTypes) {
|
||||
if (details.payloadType == payloadType) {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
return LiveTrafficPacketTypeDetails(
|
||||
payloadType: payloadType,
|
||||
title: '0x${payloadType.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
label: '0x${payloadType.toRadixString(16).padLeft(2, '0')}',
|
||||
summary: 'Unknown or application-specific protocol payload',
|
||||
description:
|
||||
'Unknown or application-specific packet type. Check the current MeshCore firmware or app-specific protocol docs for the exact payload format.',
|
||||
);
|
||||
}
|
||||
|
||||
static String payloadTypeLabel(int payloadType) {
|
||||
return payloadTypeDetails(payloadType).label;
|
||||
}
|
||||
|
||||
static String payloadTypeTitle(int payloadType) {
|
||||
return payloadTypeDetails(payloadType).title;
|
||||
}
|
||||
|
||||
static String payloadTypeMeaning(int payloadType) {
|
||||
return payloadTypeDetails(payloadType).summary;
|
||||
}
|
||||
|
||||
static String payloadTypeDescription(int payloadType) {
|
||||
return payloadTypeDetails(payloadType).description;
|
||||
}
|
||||
}
|
||||
|
||||
class LiveTrafficSnapshot {
|
||||
final DateTime windowStart;
|
||||
final Duration windowDuration;
|
||||
final int packetsPerMinute;
|
||||
final int rxCount;
|
||||
final int txCount;
|
||||
final int totalCount;
|
||||
final double? avgSnrDb;
|
||||
final double? latestSnrDb;
|
||||
final double? avgRssiDbm;
|
||||
final int? latestRssiDbm;
|
||||
final int multiHopCount;
|
||||
final double? avgHopCount;
|
||||
final List<LiveTrafficEntry> visibleEntries;
|
||||
final LiveTrafficBusyness busyness;
|
||||
|
||||
const LiveTrafficSnapshot({
|
||||
required this.windowStart,
|
||||
required this.windowDuration,
|
||||
required this.packetsPerMinute,
|
||||
required this.rxCount,
|
||||
required this.txCount,
|
||||
required this.totalCount,
|
||||
required this.avgSnrDb,
|
||||
required this.latestSnrDb,
|
||||
required this.avgRssiDbm,
|
||||
required this.latestRssiDbm,
|
||||
required this.multiHopCount,
|
||||
required this.avgHopCount,
|
||||
required this.visibleEntries,
|
||||
required this.busyness,
|
||||
});
|
||||
}
|
||||
|
||||
class LiveTrafficSummary {
|
||||
static const Duration rollingWindow = Duration(seconds: 60);
|
||||
static const int maxVisibleEntries = 120;
|
||||
static const int logRxDataResponseCode = 0x88;
|
||||
|
||||
const LiveTrafficSummary._();
|
||||
|
||||
static bool isRxDataLog(BlePacketLog log) {
|
||||
return log.direction == PacketDirection.rx &&
|
||||
log.responseCode == logRxDataResponseCode;
|
||||
}
|
||||
|
||||
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
|
||||
return logs.where((log) {
|
||||
return isRxDataLog(log) &&
|
||||
(since == null || !log.timestamp.isBefore(since));
|
||||
}).length;
|
||||
}
|
||||
|
||||
static LiveTrafficSnapshot fromLogs(
|
||||
Iterable<BlePacketLog> logs, {
|
||||
required DateTime now,
|
||||
DateTime? clearedAt,
|
||||
int? preferredHashSize,
|
||||
Duration window = rollingWindow,
|
||||
String? packetTypeFilter,
|
||||
}) {
|
||||
final windowStart = now.subtract(window);
|
||||
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||
? clearedAt
|
||||
: windowStart;
|
||||
|
||||
final recentLogs =
|
||||
logs
|
||||
.where(
|
||||
(log) =>
|
||||
isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
final entries = <LiveTrafficEntry>[];
|
||||
for (final log in recentLogs) {
|
||||
final route = LogRxRouteDecoder.decode(
|
||||
log.rawData,
|
||||
preferredHashSize: preferredHashSize,
|
||||
);
|
||||
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||
}
|
||||
|
||||
final filteredEntries = packetTypeFilter == null
|
||||
? entries
|
||||
: entries
|
||||
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||
.toList();
|
||||
|
||||
var rxCount = 0;
|
||||
var snrCount = 0;
|
||||
var snrSum = 0.0;
|
||||
var rssiCount = 0;
|
||||
var rssiSum = 0.0;
|
||||
double? latestSnrDb;
|
||||
int? latestRssiDbm;
|
||||
var multiHopCount = 0;
|
||||
var hopCountTotal = 0;
|
||||
var hopCountSamples = 0;
|
||||
|
||||
for (final entry in filteredEntries) {
|
||||
rxCount += 1;
|
||||
|
||||
final rxInfo = entry.log.logRxDataInfo;
|
||||
if (rxInfo?.snrDb != null) {
|
||||
snrCount += 1;
|
||||
snrSum += rxInfo!.snrDb!;
|
||||
latestSnrDb = rxInfo.snrDb!;
|
||||
}
|
||||
if (rxInfo?.rssiDbm != null) {
|
||||
rssiCount += 1;
|
||||
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||
latestRssiDbm = rxInfo.rssiDbm!;
|
||||
}
|
||||
|
||||
final route = entry.route;
|
||||
if (route != null && route.hopCount > 0) {
|
||||
hopCountSamples += 1;
|
||||
hopCountTotal += route.hopCount;
|
||||
if (route.hopCount > 1) {
|
||||
multiHopCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final visibleEntries = filteredEntries.reversed
|
||||
.take(maxVisibleEntries)
|
||||
.toList();
|
||||
const txCount = 0;
|
||||
final totalCount = rxCount;
|
||||
final packetsPerMinute =
|
||||
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||
|
||||
return LiveTrafficSnapshot(
|
||||
windowStart: effectiveStart,
|
||||
windowDuration: window,
|
||||
packetsPerMinute: packetsPerMinute,
|
||||
rxCount: rxCount,
|
||||
txCount: txCount,
|
||||
totalCount: totalCount,
|
||||
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||
latestSnrDb: latestSnrDb,
|
||||
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||
latestRssiDbm: latestRssiDbm,
|
||||
multiHopCount: multiHopCount,
|
||||
avgHopCount: hopCountSamples == 0
|
||||
? null
|
||||
: hopCountTotal / hopCountSamples,
|
||||
visibleEntries: visibleEntries,
|
||||
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||
);
|
||||
}
|
||||
|
||||
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||
return LiveTrafficBusyness.busy;
|
||||
}
|
||||
}
|
||||
125
lib/services/locale_preferences.dart
Normal file
125
lib/services/locale_preferences.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service for managing locale preferences
|
||||
class LocalePreferences {
|
||||
static const String _localeKey = 'app_locale';
|
||||
|
||||
/// Supported locales
|
||||
static const List<Locale> supportedLocales = [
|
||||
Locale('en'), // English
|
||||
Locale('sl'), // Slovenian
|
||||
Locale('hr'), // Croatian
|
||||
Locale('de'), // German
|
||||
Locale('es'), // Spanish
|
||||
Locale('fr'), // French
|
||||
Locale('it'), // Italian
|
||||
Locale('el'), // Greek
|
||||
Locale('pt'), // Portuguese
|
||||
Locale('tr'), // Turkish
|
||||
Locale('pl'), // Polish
|
||||
Locale('uk'), // Ukrainian
|
||||
Locale('ru'), // Russian
|
||||
Locale('zh'), // Chinese
|
||||
];
|
||||
|
||||
/// Get the saved locale or return null to use system locale
|
||||
static Future<Locale?> getLocale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final localeCode = prefs.getString(_localeKey);
|
||||
|
||||
if (localeCode == null) {
|
||||
return null; // Use system locale
|
||||
}
|
||||
|
||||
return Locale(localeCode);
|
||||
}
|
||||
|
||||
/// Save the selected locale
|
||||
static Future<void> setLocale(Locale? locale) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (locale == null) {
|
||||
// Remove preference to use system locale
|
||||
await prefs.remove(_localeKey);
|
||||
} else {
|
||||
await prefs.setString(_localeKey, locale.languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display name for a locale
|
||||
static String getDisplayName(Locale? locale) {
|
||||
if (locale == null) {
|
||||
return 'System Default';
|
||||
}
|
||||
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return 'English';
|
||||
case 'sl':
|
||||
return 'Slovenščina';
|
||||
case 'hr':
|
||||
return 'Hrvatski';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case 'it':
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Greek';
|
||||
case 'pt':
|
||||
return 'Portuguese';
|
||||
case 'tr':
|
||||
return 'Turkish';
|
||||
case 'pl':
|
||||
return 'Polish';
|
||||
case 'uk':
|
||||
return 'Ukrainian';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'zh':
|
||||
return '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get native display name for a locale (shown in selection dialog)
|
||||
static String getNativeDisplayName(Locale locale) {
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return 'English';
|
||||
case 'sl':
|
||||
return 'Slovenščina';
|
||||
case 'hr':
|
||||
return 'Hrvatski';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case 'it':
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Ελληνικά';
|
||||
case 'pt':
|
||||
return 'Português';
|
||||
case 'tr':
|
||||
return 'Türkçe';
|
||||
case 'pl':
|
||||
return 'Polski';
|
||||
case 'uk':
|
||||
return 'Українська';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'zh':
|
||||
return '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
1003
lib/services/location_tracking_service.dart
Normal file
1003
lib/services/location_tracking_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
533
lib/services/map_marker_service.dart
Normal file
533
lib/services/map_marker_service.dart
Normal file
@@ -0,0 +1,533 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../widgets/map/location_pointer.dart';
|
||||
|
||||
/// Centralized service for map marker management.
|
||||
///
|
||||
/// This service handles:
|
||||
/// - Contact marker generation
|
||||
/// - SAR marker generation
|
||||
/// - User location marker
|
||||
/// - Distance calculations (Haversine formula)
|
||||
/// - Bearing/azimuth calculations
|
||||
/// - Marker color assignment
|
||||
/// - Marker icon selection
|
||||
///
|
||||
/// Uses singleton pattern for consistent behavior across the app.
|
||||
class MapMarkerService {
|
||||
// Singleton pattern
|
||||
static final MapMarkerService _instance = MapMarkerService._internal();
|
||||
factory MapMarkerService() => _instance;
|
||||
MapMarkerService._internal();
|
||||
|
||||
/// Generate markers for team member contacts.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contacts]: List of contacts with location data
|
||||
/// - [context]: Build context for theme access
|
||||
/// - [onTap]: Callback when a marker is tapped
|
||||
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
|
||||
///
|
||||
/// Returns a list of markers positioned at contact locations.
|
||||
List<Marker> generateContactMarkers({
|
||||
required List<Contact> contacts,
|
||||
required BuildContext context,
|
||||
Function(Contact)? onTap,
|
||||
double mapRotation = 0,
|
||||
Position? userPosition,
|
||||
Map<String, LatLng>? estimatedLocations,
|
||||
}) {
|
||||
return contacts
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation ??
|
||||
estimatedLocations?[contact.publicKeyHex];
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape:
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius:
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BorderRadius.circular(14)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ContactAvatar(contact: contact, radius: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.whereType<Marker>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Generate markers for SAR events.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [sarMarkers]: List of SAR markers to display
|
||||
/// - [context]: Build context for theme access
|
||||
/// - [onTap]: Callback when a marker is tapped
|
||||
/// - [mapRotation]: Current map rotation in degrees (for counter-rotation)
|
||||
///
|
||||
/// Returns a list of markers positioned at SAR event locations.
|
||||
List<Marker> generateSarMarkers({
|
||||
required List<SarMarker> sarMarkers,
|
||||
required BuildContext context,
|
||||
Function(SarMarker)? onTap,
|
||||
double mapRotation = 0,
|
||||
LatLng Function(LatLng point)? pointTransformer,
|
||||
}) {
|
||||
return sarMarkers.map((marker) {
|
||||
return Marker(
|
||||
point: pointTransformer?.call(marker.location) ?? marker.location,
|
||||
width: 90,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(marker) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.timeAgo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Marker emoji/icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.emoji, // Use custom emoji if available
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker
|
||||
.displayName, // Uses notes if available, otherwise type.displayName
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Generate user location marker with directional pointer.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [position]: Current GPS position
|
||||
/// - [heading]: Current heading in degrees (0-360, where 0 = North)
|
||||
/// Pass null or -1 if heading unavailable
|
||||
/// - [context]: Build context for theme access
|
||||
///
|
||||
/// Returns null if position is unavailable.
|
||||
Marker? generateUserLocationMarker({
|
||||
required Position? position,
|
||||
double? heading,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
if (position == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: LatLng(position.latitude, position.longitude),
|
||||
width: 60,
|
||||
height: 60,
|
||||
rotate: false, // Don't rotate with map - we handle rotation internally
|
||||
child: LocationPointer(
|
||||
heading: heading,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 60,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate distance between two lat/lon points using Haversine formula.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: Starting latitude in decimal degrees
|
||||
/// - [lon1]: Starting longitude in decimal degrees
|
||||
/// - [lat2]: Ending latitude in decimal degrees
|
||||
/// - [lon2]: Ending longitude in decimal degrees
|
||||
///
|
||||
/// Returns distance in meters.
|
||||
double calculateDistance({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
const R = 6371000; // Earth's radius in meters
|
||||
final dLat = (lat2 - lat1) * pi / 180;
|
||||
final dLon = (lon2 - lon1) * pi / 180;
|
||||
|
||||
final a =
|
||||
sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * pi / 180) *
|
||||
cos(lat2 * pi / 180) *
|
||||
sin(dLon / 2) *
|
||||
sin(dLon / 2);
|
||||
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/// Calculate bearing/azimuth from point 1 to point 2.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: Starting latitude in decimal degrees
|
||||
/// - [lon1]: Starting longitude in decimal degrees
|
||||
/// - [lat2]: Ending latitude in decimal degrees
|
||||
/// - [lon2]: Ending longitude in decimal degrees
|
||||
///
|
||||
/// Returns bearing in degrees (0-360), where 0 is North, 90 is East.
|
||||
double calculateBearing({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
}) {
|
||||
final dLon = (lon2 - lon1) * pi / 180;
|
||||
final lat1Rad = lat1 * pi / 180;
|
||||
final lat2Rad = lat2 * pi / 180;
|
||||
|
||||
final y = sin(dLon) * cos(lat2Rad);
|
||||
final x =
|
||||
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||
|
||||
final bearing = atan2(y, x) * 180 / pi;
|
||||
return (bearing + 360) % 360;
|
||||
}
|
||||
|
||||
/// Convert bearing to cardinal direction.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [bearing]: Bearing in degrees (0-360)
|
||||
///
|
||||
/// Returns cardinal direction (N, NE, E, SE, S, SW, W, NW).
|
||||
String bearingToCardinal(double bearing) {
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
final index = ((bearing + 22.5) / 45).floor() % 8;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
/// Format distance for display.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [meters]: Distance in meters
|
||||
///
|
||||
/// Returns formatted string (e.g., "123m" or "1.2km").
|
||||
String formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.round()}m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(1)}km';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for SAR marker type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [type]: SAR marker type
|
||||
///
|
||||
/// Returns color for marker background.
|
||||
Color getSarMarkerColor(SarMarkerType type) {
|
||||
switch (type) {
|
||||
case SarMarkerType.foundPerson:
|
||||
return Colors.green;
|
||||
case SarMarkerType.fire:
|
||||
return Colors.red;
|
||||
case SarMarkerType.stagingArea:
|
||||
return Colors.orange;
|
||||
case SarMarkerType.object:
|
||||
return Colors.purple;
|
||||
case SarMarkerType.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for contact marker based on contact type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to get color for
|
||||
/// - [context]: Build context for theme access
|
||||
///
|
||||
/// Returns color for marker background.
|
||||
Color getContactMarkerColor(Contact contact, BuildContext context) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Theme.of(context).colorScheme.primary; // Blue for team members
|
||||
case ContactType.repeater:
|
||||
return Colors.deepPurple; // Purple for repeaters
|
||||
case ContactType.room:
|
||||
return Colors.teal; // Teal for rooms
|
||||
case ContactType.sensor:
|
||||
return Colors.green; // Green for sensors
|
||||
case ContactType.channel:
|
||||
return Colors.orange; // Orange for channels
|
||||
case ContactType.none:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get icon for contact marker based on contact type.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to get icon for
|
||||
///
|
||||
/// Returns icon data for marker.
|
||||
IconData getContactMarkerIcon(Contact contact) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Icons.person; // Person for team members
|
||||
case ContactType.repeater:
|
||||
return Icons.router; // Router icon for repeaters
|
||||
case ContactType.room:
|
||||
return Icons.forum; // Forum/chat icon for rooms
|
||||
case ContactType.sensor:
|
||||
return Icons.sensors; // Sensors icon for sensor nodes
|
||||
case ContactType.channel:
|
||||
return Icons.public; // Public icon for channels
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get color for location age indicator.
|
||||
///
|
||||
/// Color indicates how recent the location update is:
|
||||
/// - Green: < 5 minutes (very recent)
|
||||
/// - Light blue: 5-30 minutes (recent)
|
||||
/// - Orange: 30 minutes - 2 hours (getting old)
|
||||
/// - Red: > 2 hours (stale)
|
||||
/// - Grey: Unknown
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contact]: Contact to check location age for
|
||||
///
|
||||
/// Returns color for location age indicator.
|
||||
Color getLocationAgeColor(Contact contact) {
|
||||
final updateTime = contact.locationUpdateTime;
|
||||
if (updateTime == null) return Colors.grey;
|
||||
|
||||
final diff = DateTime.now().difference(updateTime);
|
||||
if (diff.inMinutes < 5) return Colors.green; // Very recent
|
||||
if (diff.inMinutes < 30) return Colors.lightBlue; // Recent
|
||||
if (diff.inHours < 2) return Colors.orange; // Getting old
|
||||
return Colors.red; // Stale
|
||||
}
|
||||
|
||||
/// Cluster markers if too many are visible.
|
||||
///
|
||||
/// This is a placeholder for future clustering implementation.
|
||||
/// When implemented, it should group nearby markers into clusters
|
||||
/// to improve performance and reduce visual clutter.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [markers]: All markers to potentially cluster
|
||||
/// - [maxVisibleMarkers]: Maximum number of individual markers to show
|
||||
///
|
||||
/// Returns list of markers (clustered or original).
|
||||
List<Marker> clusterMarkers({
|
||||
required List<Marker> markers,
|
||||
required int maxVisibleMarkers,
|
||||
}) {
|
||||
// TODO: Implement marker clustering algorithm
|
||||
// For now, just return all markers
|
||||
return markers;
|
||||
}
|
||||
|
||||
/// Calculate optimal map center from list of points.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [contacts]: Contacts with locations
|
||||
/// - [sarMarkers]: SAR markers with locations
|
||||
/// - [defaultCenter]: Fallback center if no points available
|
||||
///
|
||||
/// Returns center point (average of all locations).
|
||||
LatLng calculateCenter({
|
||||
required List<Contact> contacts,
|
||||
required List<SarMarker> sarMarkers,
|
||||
LatLng? defaultCenter,
|
||||
}) {
|
||||
final allPoints = <LatLng>[];
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (contact.displayLocation != null) {
|
||||
allPoints.add(contact.displayLocation!);
|
||||
}
|
||||
}
|
||||
|
||||
for (final marker in sarMarkers) {
|
||||
allPoints.add(marker.location);
|
||||
}
|
||||
|
||||
if (allPoints.isEmpty) {
|
||||
return defaultCenter ??
|
||||
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||
}
|
||||
|
||||
double lat = 0, lng = 0;
|
||||
for (final point in allPoints) {
|
||||
lat += point.latitude;
|
||||
lng += point.longitude;
|
||||
}
|
||||
|
||||
return LatLng(lat / allPoints.length, lng / allPoints.length);
|
||||
}
|
||||
|
||||
/// Check if two positions are close enough to be considered the same location.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [lat1]: First latitude
|
||||
/// - [lon1]: First longitude
|
||||
/// - [lat2]: Second latitude
|
||||
/// - [lon2]: Second longitude
|
||||
/// - [thresholdMeters]: Distance threshold in meters (default: 50)
|
||||
///
|
||||
/// Returns true if points are within threshold distance.
|
||||
bool isNearby({
|
||||
required double lat1,
|
||||
required double lon1,
|
||||
required double lat2,
|
||||
required double lon2,
|
||||
double thresholdMeters = 50,
|
||||
}) {
|
||||
final distance = calculateDistance(
|
||||
lat1: lat1,
|
||||
lon1: lon1,
|
||||
lat2: lat2,
|
||||
lon2: lon2,
|
||||
);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
}
|
||||
99
lib/services/map_workspace_snapshot_service.dart
Normal file
99
lib/services/map_workspace_snapshot_service.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
class MapWorkspaceSnapshotService {
|
||||
Future<MapWorkspaceProfileSection> capture({
|
||||
required MapProvider mapProvider,
|
||||
required DrawingProvider drawingProvider,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return MapWorkspaceProfileSection(
|
||||
mapPrefs: {
|
||||
'map_rotate_with_heading':
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||
) ??
|
||||
false,
|
||||
'map_show_debug_info':
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_show_debug_info'),
|
||||
) ??
|
||||
false,
|
||||
'map_fullscreen':
|
||||
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
|
||||
false,
|
||||
'map_last_latitude': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_latitude'),
|
||||
),
|
||||
'map_last_longitude': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_longitude'),
|
||||
),
|
||||
'map_last_zoom': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_zoom'),
|
||||
),
|
||||
'map_last_layer_type': prefs.getInt(
|
||||
ProfileStorageScope.scopedKey('map_last_layer_type'),
|
||||
),
|
||||
'map_gps_update_distance': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_gps_update_distance'),
|
||||
),
|
||||
'background_tracking_enabled': prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||
),
|
||||
},
|
||||
drawings: drawingProvider.exportDrawingsJson(),
|
||||
currentTrail: mapProvider.currentTrail?.toJson(),
|
||||
trailHistory: mapProvider.trailHistory
|
||||
.map((trail) => trail.toJson())
|
||||
.toList(),
|
||||
importedTrail: mapProvider.importedTrail?.toJson(),
|
||||
isTrailVisible: mapProvider.isTrailVisible,
|
||||
showCadastralOverlay: mapProvider.showCadastralOverlay,
|
||||
showForestRoadsOverlay: mapProvider.showForestRoadsOverlay,
|
||||
showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay,
|
||||
showMainRoadsOverlay: mapProvider.showMainRoadsOverlay,
|
||||
showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay,
|
||||
showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay,
|
||||
showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay,
|
||||
showFirebreaksOverlay: mapProvider.showFirebreaksOverlay,
|
||||
showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay,
|
||||
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
|
||||
showMunicipalityBordersOverlay:
|
||||
mapProvider.showMunicipalityBordersOverlay,
|
||||
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
MapWorkspaceProfileSection? section, {
|
||||
required MapProvider mapProvider,
|
||||
required DrawingProvider drawingProvider,
|
||||
}) async {
|
||||
if (section == null || section.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mapPrefs = section.mapPrefs ?? const <String, dynamic>{};
|
||||
for (final entry in mapPrefs.entries) {
|
||||
final key = ProfileStorageScope.scopedKey(entry.key);
|
||||
final value = entry.value;
|
||||
if (value == null) {
|
||||
await prefs.remove(key);
|
||||
} else if (value is bool) {
|
||||
await prefs.setBool(key, value);
|
||||
} else if (value is int) {
|
||||
await prefs.setInt(key, value);
|
||||
} else if (value is double) {
|
||||
await prefs.setDouble(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapProvider.applyWorkspaceJson(section.toJson());
|
||||
await drawingProvider.replaceDrawingsFromJson(section.drawings);
|
||||
}
|
||||
}
|
||||
217
lib/services/mesh_map_nodes_service.dart
Normal file
217
lib/services/mesh_map_nodes_service.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MeshMapNode {
|
||||
final int type;
|
||||
final String name;
|
||||
final String publicKey;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final int updatedAtMs;
|
||||
|
||||
const MeshMapNode({
|
||||
required this.type,
|
||||
required this.name,
|
||||
required this.publicKey,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
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,
|
||||
name: (json['name'] as String?)?.trim() ?? 'Unknown',
|
||||
publicKey: ((json['public_key'] as String?) ?? '').toLowerCase(),
|
||||
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||
updatedAtMs: (json['updated_at'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MeshMapNodesService {
|
||||
static const String _nodesEndpoint =
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const int repeaterType = 1;
|
||||
static const Duration _cacheTtl = Duration(hours: 24);
|
||||
static const Duration _requestTimeout = Duration(seconds: 30);
|
||||
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||
static List<MeshMapNode>? _cachedNodes;
|
||||
static DateTime? _cachedAt;
|
||||
static Future<void>? _ongoingSync;
|
||||
|
||||
static Future<List<MeshMapNode>> fetchNodes({
|
||||
bool forceRefresh = false,
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
bool allowNetwork = true,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (!forceRefresh &&
|
||||
cached.isNotEmpty &&
|
||||
_cachedAt != null &&
|
||||
now.difference(_cachedAt!) < cacheTtl) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!allowNetwork) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final response = await (client ?? http.Client())
|
||||
.get(Uri.parse(_nodesEndpoint))
|
||||
.timeout(_requestTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final nodesRaw = decoded['nodes'] as List<dynamic>? ?? const [];
|
||||
|
||||
final nodes = nodesRaw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshMapNode.fromJson)
|
||||
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||
.toList();
|
||||
|
||||
await _storeCache(nodes, cachedAt: now);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (_cachedNodes != null &&
|
||||
_cachedAt != null &&
|
||||
now.difference(_cachedAt!) < cacheTtl) {
|
||||
return _cachedNodes!;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||
final cachedJson = prefs.getString(_cacheKey);
|
||||
if (cachedAtMs == null || cachedJson == null) {
|
||||
_cachedNodes = null;
|
||||
_cachedAt = null;
|
||||
return const [];
|
||||
}
|
||||
|
||||
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||
if (now.difference(cachedAt) >= cacheTtl) {
|
||||
_cachedNodes = null;
|
||||
_cachedAt = cachedAt;
|
||||
return const [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||
final nodes = decoded
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshMapNode.fromJson)
|
||||
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||
.toList();
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = cachedAt;
|
||||
return nodes;
|
||||
}
|
||||
|
||||
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
return nodes.isNotEmpty;
|
||||
}
|
||||
|
||||
static Future<void> syncInBackgroundIfStale({
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||
return;
|
||||
}
|
||||
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (cached.isNotEmpty && _cachedAt != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ongoingSync != null) {
|
||||
return _ongoingSync!;
|
||||
}
|
||||
|
||||
_ongoingSync = () async {
|
||||
try {
|
||||
await fetchNodes(
|
||||
forceRefresh: true,
|
||||
cacheTtl: cacheTtl,
|
||||
allowNetwork: true,
|
||||
client: client,
|
||||
);
|
||||
} catch (_) {
|
||||
// Background refresh is best-effort only.
|
||||
} finally {
|
||||
_ongoingSync = null;
|
||||
}
|
||||
}();
|
||||
|
||||
return _ongoingSync!;
|
||||
}
|
||||
|
||||
static Future<void> clearCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_cacheKey);
|
||||
await prefs.remove(_cacheTimestampKey);
|
||||
_cachedNodes = null;
|
||||
_cachedAt = null;
|
||||
}
|
||||
|
||||
static Future<DateTime?> cachedAt() async {
|
||||
if (_cachedAt != null) return _cachedAt;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||
if (cachedAtMs == null) return null;
|
||||
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||
return _cachedAt;
|
||||
}
|
||||
|
||||
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
|
||||
|
||||
static Future<void> _storeCache(
|
||||
List<MeshMapNode> nodes, {
|
||||
required DateTime cachedAt,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_cacheKey,
|
||||
jsonEncode(
|
||||
nodes
|
||||
.map(
|
||||
(node) => {
|
||||
'type': node.type,
|
||||
'name': node.name,
|
||||
'public_key': node.publicKey,
|
||||
'latitude': node.latitude,
|
||||
'longitude': node.longitude,
|
||||
'updated_at': node.updatedAtMs,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = cachedAt;
|
||||
}
|
||||
}
|
||||
128
lib/services/message_destination_preferences.dart
Normal file
128
lib/services/message_destination_preferences.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service for managing message destination preferences
|
||||
/// Stores the last selected recipient (channel, contact, or room) for sending messages
|
||||
class MessageDestinationPreferences {
|
||||
static const String _destinationTypeKey = 'message_destination_type';
|
||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
||||
static const String _lockedDestinationEnabledKey =
|
||||
'message_locked_destination_enabled';
|
||||
static const String _lockedDestinationTypeKey =
|
||||
'message_locked_destination_type';
|
||||
static const String _lockedRecipientPublicKeyKey =
|
||||
'message_locked_recipient_public_key';
|
||||
|
||||
/// Destination types
|
||||
static const String destinationTypeAll = 'all';
|
||||
static const String destinationTypeChannel = 'channel';
|
||||
static const String destinationTypeContact = 'contact';
|
||||
static const String destinationTypeRoom = 'room';
|
||||
|
||||
static bool isLockableDestinationType(String type) {
|
||||
return type == destinationTypeChannel || type == destinationTypeRoom;
|
||||
}
|
||||
|
||||
/// Get the saved destination configuration
|
||||
/// Returns a map with 'type' and optional 'publicKey'
|
||||
/// Returns null if no preference is saved (defaults to public channel)
|
||||
static Future<Map<String, String>?> getDestination() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final type = prefs.getString(_destinationTypeKey);
|
||||
|
||||
if (type == null) {
|
||||
return null; // Use default (public channel)
|
||||
}
|
||||
|
||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||
|
||||
return {'type': type, 'publicKey': ?publicKey};
|
||||
}
|
||||
|
||||
/// Save the selected destination
|
||||
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
||||
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
||||
static Future<void> setDestination(
|
||||
String type, {
|
||||
String? recipientPublicKey,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setString(_destinationTypeKey, type);
|
||||
|
||||
if (recipientPublicKey != null) {
|
||||
await prefs.setString(_recipientPublicKeyKey, recipientPublicKey);
|
||||
} else {
|
||||
await prefs.remove(_recipientPublicKeyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the saved destination (resets to default public channel)
|
||||
static Future<void> clearDestination() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_destinationTypeKey);
|
||||
await prefs.remove(_recipientPublicKeyKey);
|
||||
}
|
||||
|
||||
/// Get the saved locked destination configuration.
|
||||
/// Returns null when the lock is disabled.
|
||||
static Future<Map<String, String>?> getLockedDestination() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isEnabled = prefs.getBool(_lockedDestinationEnabledKey) ?? false;
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final savedType =
|
||||
prefs.getString(_lockedDestinationTypeKey) ?? destinationTypeChannel;
|
||||
final type = isLockableDestinationType(savedType)
|
||||
? savedType
|
||||
: destinationTypeChannel;
|
||||
final publicKey = prefs.getString(_lockedRecipientPublicKeyKey);
|
||||
|
||||
return {'type': type, 'publicKey': ?publicKey};
|
||||
}
|
||||
|
||||
static Future<void> setLockedDestination({
|
||||
required bool enabled,
|
||||
String type = destinationTypeChannel,
|
||||
String? recipientPublicKey,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setBool(_lockedDestinationEnabledKey, enabled);
|
||||
|
||||
if (!enabled) {
|
||||
await prefs.remove(_lockedDestinationTypeKey);
|
||||
await prefs.remove(_lockedRecipientPublicKeyKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitizedType = isLockableDestinationType(type)
|
||||
? type
|
||||
: destinationTypeChannel;
|
||||
await prefs.setString(_lockedDestinationTypeKey, sanitizedType);
|
||||
|
||||
if (recipientPublicKey != null) {
|
||||
await prefs.setString(_lockedRecipientPublicKeyKey, recipientPublicKey);
|
||||
} else {
|
||||
await prefs.remove(_lockedRecipientPublicKeyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get display name for destination type
|
||||
static String getDestinationTypeName(String type) {
|
||||
switch (type) {
|
||||
case destinationTypeAll:
|
||||
return 'All';
|
||||
case destinationTypeChannel:
|
||||
return 'Channel';
|
||||
case destinationTypeContact:
|
||||
return 'Contact';
|
||||
case destinationTypeRoom:
|
||||
return 'Room';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
667
lib/services/message_storage_service.dart
Normal file
667
lib/services/message_storage_service.dart
Normal file
@@ -0,0 +1,667 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||
static const String _messageContactLocationsKey =
|
||||
'stored_message_contact_locations';
|
||||
static const String _messageReceptionDetailsKey =
|
||||
'stored_message_reception_details';
|
||||
static const String _messageTransferDetailsKey =
|
||||
'stored_message_transfer_details';
|
||||
static const String _messageRouteMetadataKey =
|
||||
'stored_message_route_metadata';
|
||||
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||
|
||||
String _key(String baseKey, {String? namespace}) {
|
||||
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||
}
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||
// survive restore even if the sidecar reception-details entry is absent.
|
||||
final jsonList = messages
|
||||
.map(
|
||||
(msg) => _messageToJson(
|
||||
msg,
|
||||
receptionDetails: messageReceptionDetails[msg.id],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Limit to max stored messages (keep most recent)
|
||||
final limitedList = jsonList.length > _maxStoredMessages
|
||||
? jsonList.sublist(jsonList.length - _maxStoredMessages)
|
||||
: jsonList;
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
final messagesKey = _key(_messagesKey, namespace: namespace);
|
||||
final retainedMessageIds = limitedList
|
||||
.map((entry) => entry['id'] as String)
|
||||
.toSet();
|
||||
final locationJson = <String, dynamic>{};
|
||||
final receptionJson = <String, dynamic>{};
|
||||
final transferJson = <String, dynamic>{};
|
||||
final routeMetadataJson = <String, dynamic>{};
|
||||
for (final entry in messageContactLocations.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
locationJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageReceptionDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
receptionJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageTransferDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
transferJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageRouteMetadata.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
routeMetadataJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
final contactLocationsKey = _key(
|
||||
_messageContactLocationsKey,
|
||||
namespace: namespace,
|
||||
);
|
||||
final receptionDetailsKey = _key(
|
||||
_messageReceptionDetailsKey,
|
||||
namespace: namespace,
|
||||
);
|
||||
final transferDetailsKey = _key(
|
||||
_messageTransferDetailsKey,
|
||||
namespace: namespace,
|
||||
);
|
||||
final routeMetadataKey = _key(
|
||||
_messageRouteMetadataKey,
|
||||
namespace: namespace,
|
||||
);
|
||||
final locationJsonString = jsonEncode(locationJson);
|
||||
final receptionJsonString = jsonEncode(receptionJson);
|
||||
final transferJsonString = jsonEncode(transferJson);
|
||||
final routeMetadataJsonString = jsonEncode(routeMetadataJson);
|
||||
final hasChanges =
|
||||
prefs.getString(messagesKey) != jsonString ||
|
||||
prefs.getString(contactLocationsKey) != locationJsonString ||
|
||||
prefs.getString(receptionDetailsKey) != receptionJsonString ||
|
||||
prefs.getString(transferDetailsKey) != transferJsonString ||
|
||||
prefs.getString(routeMetadataKey) != routeMetadataJsonString;
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.setString(messagesKey, jsonString);
|
||||
await prefs.setString(contactLocationsKey, locationJsonString);
|
||||
await prefs.setString(receptionDetailsKey, receptionJsonString);
|
||||
await prefs.setString(transferDetailsKey, transferJsonString);
|
||||
await prefs.setString(routeMetadataKey, routeMetadataJsonString);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error saving messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageContactLocationsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageContactLocation>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageContactLocation.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading contact locations: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>> loadMessageReceptionDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||
);
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
if (jsonString != null && jsonString.isNotEmpty) {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
|
||||
namespace: namespace,
|
||||
);
|
||||
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||
result.putIfAbsent(messageId, () => snapshot);
|
||||
});
|
||||
|
||||
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
|
||||
namespace: namespace,
|
||||
);
|
||||
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||
result.putIfAbsent(
|
||||
messageId,
|
||||
() => MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
pathBytes: pathBytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageTransferDetails>> loadMessageTransferDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageTransferDetails>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final details = MessageTransferDetails.fromJson(value);
|
||||
if (details != null) {
|
||||
result[entry.key] = details;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading transfer details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageRouteMetadataKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageRouteMetadata>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
result[entry.key] = MessageRouteMetadata.fromJson(value);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
debugPrint('ℹ️ [MessageStorage] No stored messages found');
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
final messages = jsonList
|
||||
.map((json) => _messageFromJson(json as Map<String, dynamic>))
|
||||
.where((msg) => msg != null)
|
||||
.cast<Message>()
|
||||
.toList();
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Loaded ${messages.length} messages from storage',
|
||||
);
|
||||
return messages;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading messages: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all stored messages
|
||||
Future<void> clearMessages({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_messagesKey, namespace: namespace));
|
||||
await prefs.remove(
|
||||
_key(_messageContactLocationsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(
|
||||
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(
|
||||
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace));
|
||||
await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace));
|
||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ids =
|
||||
prefs.getStringList(
|
||||
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||
) ??
|
||||
const [];
|
||||
return ids.toSet();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||
return const <String>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRemovedSarMarkerIds(
|
||||
Set<String> ids, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||
ids.toList()..sort(),
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
|
||||
final sizeBytes = jsonString.length;
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
|
||||
return {
|
||||
'messageCount': jsonList.length,
|
||||
'storageSizeBytes': sizeBytes,
|
||||
'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2),
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error getting storage stats: $e');
|
||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Message to JSON
|
||||
Map<String, dynamic> _messageToJson(
|
||||
Message message, {
|
||||
MessageReceptionDetails? receptionDetails,
|
||||
}) {
|
||||
final persistedPathBytes =
|
||||
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||
return {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null
|
||||
? base64Encode(message.senderPublicKeyPrefix!)
|
||||
: null,
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'pathBytes': persistedPathBytes?.toList(),
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
'isSarMarker': message.isSarMarker,
|
||||
'sarGpsLat': message.sarGpsCoordinates?.latitude,
|
||||
'sarGpsLon': message.sarGpsCoordinates?.longitude,
|
||||
'sarCustomMapLat': message.sarCustomMapPoint?.latitude,
|
||||
'sarCustomMapLon': message.sarCustomMapPoint?.longitude,
|
||||
'sarCustomMapId': message.sarCustomMapId,
|
||||
'sarNotes': message.sarNotes,
|
||||
'sarCustomEmoji': message.sarCustomEmoji,
|
||||
'sarColorIndex': message.sarColorIndex,
|
||||
'receivedAtMillis': message.receivedAt.millisecondsSinceEpoch,
|
||||
'senderName': message.senderName,
|
||||
'deliveryStatus': message.deliveryStatus.name,
|
||||
'expectedAckTag': message.expectedAckTag,
|
||||
'suggestedTimeoutMs': message.suggestedTimeoutMs,
|
||||
'roundTripTimeMs': message.roundTripTimeMs,
|
||||
'deliveredAtMillis': message.deliveredAt?.millisecondsSinceEpoch,
|
||||
'recipientPublicKey': message.recipientPublicKey != null
|
||||
? base64Encode(message.recipientPublicKey!)
|
||||
: null,
|
||||
'isRead': message.isRead,
|
||||
// Retry state tracking (IMPORTANT for preserving state across app restarts)
|
||||
'retryAttempt': message.retryAttempt,
|
||||
'lastRetryAtMillis': message.lastRetryAt?.millisecondsSinceEpoch,
|
||||
'usedFloodFallback': message.usedFloodFallback,
|
||||
// Echo detection for channel messages
|
||||
'echoCount': message.echoCount,
|
||||
'firstEchoAtMillis': message.firstEchoAt?.millisecondsSinceEpoch,
|
||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
||||
'lastEchoAtMillis': message.lastEchoAt?.millisecondsSinceEpoch,
|
||||
// Drawing message tracking
|
||||
'isDrawing': message.isDrawing,
|
||||
'drawingId': message.drawingId,
|
||||
// Voice message tracking
|
||||
'isVoice': message.isVoice,
|
||||
'voiceId': message.voiceId,
|
||||
// Message grouping (for bulk sends)
|
||||
'groupId': message.groupId,
|
||||
'recipients': message.recipients
|
||||
?.map(
|
||||
(r) => {
|
||||
'publicKey': base64Encode(r.publicKey),
|
||||
'displayName': r.displayName,
|
||||
'deliveryStatus': r.deliveryStatus.name,
|
||||
'expectedAckTag': r.expectedAckTag,
|
||||
'roundTripTimeMs': r.roundTripTimeMs,
|
||||
'deliveredAtMillis': r.deliveredAt?.millisecondsSinceEpoch,
|
||||
'sentAtMillis': r.sentAt.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
if (receptionDetails != null)
|
||||
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final messageId = entry['id'];
|
||||
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||
if (snapshot == null) continue;
|
||||
result[messageId] = snapshot;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages({
|
||||
String? namespace,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final messageId = entry['id'];
|
||||
final pathBytes = entry[_legacyPathBytesKey];
|
||||
if (messageId is! String || pathBytes is! List) continue;
|
||||
final normalized = pathBytes
|
||||
.whereType<num>()
|
||||
.map((b) => b.toInt())
|
||||
.toList();
|
||||
if (normalized.isEmpty) continue;
|
||||
result[messageId] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Convert JSON to Message
|
||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return Message(
|
||||
id: json['id'] as String,
|
||||
messageType: MessageType.values.firstWhere(
|
||||
(e) => e.name == json['messageType'],
|
||||
orElse: () => MessageType.contact,
|
||||
),
|
||||
senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['senderPublicKeyPrefix'] as String),
|
||||
)
|
||||
: null,
|
||||
channelIdx: json['channelIdx'] as int?,
|
||||
pathLen: json['pathLen'] as int,
|
||||
pathBytes: json['pathBytes'] is List
|
||||
? Uint8List.fromList(
|
||||
(json['pathBytes'] as List<dynamic>)
|
||||
.whereType<num>()
|
||||
.map((b) => b.toInt())
|
||||
.toList(),
|
||||
)
|
||||
: null,
|
||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||
senderTimestamp: json['senderTimestamp'] as int,
|
||||
text: json['text'] as String,
|
||||
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
||||
sarGpsCoordinates:
|
||||
json['sarGpsLat'] != null && json['sarGpsLon'] != null
|
||||
? LatLng(
|
||||
(json['sarGpsLat'] as num).toDouble(),
|
||||
(json['sarGpsLon'] as num).toDouble(),
|
||||
)
|
||||
: null,
|
||||
sarCustomMapPoint:
|
||||
json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null
|
||||
? LatLng(
|
||||
(json['sarCustomMapLat'] as num).toDouble(),
|
||||
(json['sarCustomMapLon'] as num).toDouble(),
|
||||
)
|
||||
: null,
|
||||
sarCustomMapId: json['sarCustomMapId'] as String?,
|
||||
sarNotes: json['sarNotes'] as String?,
|
||||
sarCustomEmoji: json['sarCustomEmoji'] as String?,
|
||||
sarColorIndex: json['sarColorIndex'] as int?,
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['receivedAtMillis'] as int,
|
||||
),
|
||||
senderName: json['senderName'] as String?,
|
||||
deliveryStatus: json['deliveryStatus'] != null
|
||||
? MessageDeliveryStatus.values.firstWhere(
|
||||
(e) => e.name == json['deliveryStatus'],
|
||||
orElse: () => MessageDeliveryStatus.received,
|
||||
)
|
||||
: MessageDeliveryStatus.received,
|
||||
expectedAckTag: json['expectedAckTag'] as int?,
|
||||
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
|
||||
roundTripTimeMs: json['roundTripTimeMs'] as int?,
|
||||
deliveredAt: json['deliveredAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['deliveredAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
recipientPublicKey: json['recipientPublicKey'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['recipientPublicKey'] as String),
|
||||
)
|
||||
: null,
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
// Retry state tracking (preserves retry/flood state across restarts)
|
||||
retryAttempt: json['retryAttempt'] as int? ?? 0,
|
||||
lastRetryAt: json['lastRetryAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastRetryAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
|
||||
// Echo detection
|
||||
echoCount: json['echoCount'] as int? ?? 0,
|
||||
firstEchoAt: json['firstEchoAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['firstEchoAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
lastEchoSnrRaw: json['lastEchoSnrRaw'] as int?,
|
||||
lastEchoRssiDbm: json['lastEchoRssiDbm'] as int?,
|
||||
lastEchoAt: json['lastEchoAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['lastEchoAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
// Drawing message tracking
|
||||
isDrawing: json['isDrawing'] as bool? ?? false,
|
||||
drawingId: json['drawingId'] as String?,
|
||||
// Voice message tracking
|
||||
isVoice: json['isVoice'] as bool? ?? false,
|
||||
voiceId: json['voiceId'] as String?,
|
||||
// Message grouping
|
||||
groupId: json['groupId'] as String?,
|
||||
recipients: json['recipients'] != null
|
||||
? (json['recipients'] as List<dynamic>)
|
||||
.map(
|
||||
(r) => MessageRecipient(
|
||||
publicKey: Uint8List.fromList(
|
||||
base64Decode(r['publicKey'] as String),
|
||||
),
|
||||
displayName: r['displayName'] as String,
|
||||
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
|
||||
(e) => e.name == r['deliveryStatus'],
|
||||
orElse: () => MessageDeliveryStatus.sending,
|
||||
),
|
||||
expectedAckTag: r['expectedAckTag'] as int?,
|
||||
roundTripTimeMs: r['roundTripTimeMs'] as int?,
|
||||
deliveredAt: r['deliveredAtMillis'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
r['deliveredAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
sentAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
r['sentAtMillis'] as int,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
: null,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error parsing message from JSON: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||
final pathBytes = receptionDetails?.pathBytes;
|
||||
if (pathBytes == null || pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(pathBytes);
|
||||
}
|
||||
}
|
||||
53
lib/services/messaging_route_preferences.dart
Normal file
53
lib/services/messaging_route_preferences.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
class MessagingRoutePreferences {
|
||||
static const bool defaultClearPathOnMaxRetry = false;
|
||||
static const bool defaultNearestRelayFallbackEnabled = true;
|
||||
|
||||
static const String _legacyAutoRouteRotationKey =
|
||||
'messaging_auto_route_rotation_enabled';
|
||||
static const String _clearPathOnMaxRetryKey =
|
||||
'messaging_clear_path_on_max_retry';
|
||||
static const String _nearestRelayFallbackKey =
|
||||
'messaging_nearest_relay_fallback_enabled';
|
||||
|
||||
static Future<void> cleanupLegacySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(
|
||||
ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getClearPathOnMaxRetry() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(
|
||||
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||
) ??
|
||||
defaultClearPathOnMaxRetry;
|
||||
}
|
||||
|
||||
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getNearestRelayFallbackEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(
|
||||
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||
) ??
|
||||
defaultNearestRelayFallbackEnabled;
|
||||
}
|
||||
|
||||
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/services/nearest_router_selector.dart
Normal file
69
lib/services/nearest_router_selector.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class NearestRouterSelector {
|
||||
const NearestRouterSelector();
|
||||
|
||||
Contact? select({
|
||||
required Position? senderPosition,
|
||||
required List<Contact> repeaters,
|
||||
required Contact recipient,
|
||||
}) {
|
||||
if (senderPosition == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final eligible = repeaters.where((contact) {
|
||||
if (contact.publicKeyHex == recipient.publicKeyHex) {
|
||||
return false;
|
||||
}
|
||||
if (!contact.isRecentlySeen) {
|
||||
return false;
|
||||
}
|
||||
return contact.displayLocation != null;
|
||||
}).toList();
|
||||
if (eligible.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
eligible.sort((a, b) {
|
||||
final locationA = a.displayLocation!;
|
||||
final locationB = b.displayLocation!;
|
||||
final distanceA = Geolocator.distanceBetween(
|
||||
senderPosition.latitude,
|
||||
senderPosition.longitude,
|
||||
locationA.latitude,
|
||||
locationA.longitude,
|
||||
);
|
||||
final distanceB = Geolocator.distanceBetween(
|
||||
senderPosition.latitude,
|
||||
senderPosition.longitude,
|
||||
locationB.latitude,
|
||||
locationB.longitude,
|
||||
);
|
||||
final distanceCompare = distanceA.compareTo(distanceB);
|
||||
if (distanceCompare != 0) {
|
||||
return distanceCompare;
|
||||
}
|
||||
|
||||
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
|
||||
if (advertCompare != 0) {
|
||||
return advertCompare;
|
||||
}
|
||||
|
||||
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
|
||||
if (hopCompare != 0) {
|
||||
return hopCompare;
|
||||
}
|
||||
|
||||
final nameCompare = a.advName.compareTo(b.advName);
|
||||
if (nameCompare != 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||
});
|
||||
|
||||
return eligible.first;
|
||||
}
|
||||
}
|
||||
282
lib/services/network_scanner_service.dart
Normal file
282
lib/services/network_scanner_service.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
|
||||
/// Discovered MeshCore device on the network (TCP/WiFi)
|
||||
class DiscoveredServer {
|
||||
final String name;
|
||||
final String ipAddress;
|
||||
final int port;
|
||||
final int responseTime; // milliseconds
|
||||
|
||||
const DiscoveredServer({
|
||||
required this.name,
|
||||
required this.ipAddress,
|
||||
required this.port,
|
||||
required this.responseTime,
|
||||
});
|
||||
|
||||
String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is DiscoveredServer &&
|
||||
other.ipAddress == ipAddress &&
|
||||
other.port == port;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(ipAddress, port);
|
||||
}
|
||||
|
||||
/// Discovers MeshCore devices running the TCP/WiFi server (port 5000).
|
||||
///
|
||||
/// First tries mDNS/Bonjour (_meshcore._tcp), then falls back to a parallel
|
||||
/// TCP-connect port scan of the local /24 subnet.
|
||||
class NetworkScannerService {
|
||||
static const int defaultPort = 5000;
|
||||
static const String serviceType = '_meshcore._tcp';
|
||||
static const int parallelScans = 20;
|
||||
static const Duration connectTimeout = Duration(seconds: 2);
|
||||
static const Duration bonjourTimeout = Duration(seconds: 5);
|
||||
|
||||
Discovery? _activeDiscovery;
|
||||
|
||||
Function(DiscoveredServer)? onServerDiscovered;
|
||||
Function(int scanned, int total)? onProgressUpdate;
|
||||
|
||||
bool _isScanning = false;
|
||||
bool get isScanning => _isScanning;
|
||||
int _scanSession = 0;
|
||||
|
||||
List<DiscoveredServer> _cachedServers = [];
|
||||
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
|
||||
bool get hasCachedResults => _cachedServers.isNotEmpty;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<Set<String>> _getLocalIpAddresses() async {
|
||||
final ips = <String>{};
|
||||
try {
|
||||
for (final iface in await NetworkInterface.list()) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4) ips.add(addr.address);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
Future<List<String>> _getLocalNetworkRange() async {
|
||||
try {
|
||||
for (final iface in await NetworkInterface.list()) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
final parts = addr.address.split('.');
|
||||
if (parts.length == 4) {
|
||||
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
||||
debugPrint('📡 [NetworkScanner] Scanning subnet $subnet.0/24');
|
||||
return [for (int i = 1; i <= 254; i++) '$subnet.$i'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [NetworkScanner] Error getting network range: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
|
||||
Future<DiscoveredServer?> _checkDevice(
|
||||
String ip,
|
||||
int port, {
|
||||
String? name,
|
||||
}) async {
|
||||
final sw = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
ip,
|
||||
port,
|
||||
timeout: connectTimeout,
|
||||
);
|
||||
sw.stop();
|
||||
debugPrint(
|
||||
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
|
||||
return DiscoveredServer(
|
||||
name: (name ?? ip).trim(),
|
||||
ipAddress: ip,
|
||||
port: port,
|
||||
responseTime: sw.elapsedMilliseconds,
|
||||
);
|
||||
} on SocketException {
|
||||
// Connection refused or timed out — no device here
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port — $e');
|
||||
} finally {
|
||||
socket?.destroy();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── mDNS discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<DiscoveredServer>> _discoverViaMdns({
|
||||
int? port,
|
||||
required int scanSession,
|
||||
}) async {
|
||||
final scanPort = port ?? defaultPort;
|
||||
final found = <DiscoveredServer>[];
|
||||
|
||||
try {
|
||||
debugPrint('🔍 [NetworkScanner] mDNS discovery for $serviceType...');
|
||||
final localIps = await _getLocalIpAddresses();
|
||||
|
||||
_activeDiscovery = await startDiscovery(
|
||||
serviceType,
|
||||
ipLookupType: IpLookupType.any,
|
||||
);
|
||||
|
||||
await Future.delayed(bonjourTimeout);
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
return found;
|
||||
}
|
||||
|
||||
for (final service in _activeDiscovery?.services ?? []) {
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
break;
|
||||
}
|
||||
for (final addr in service.addresses ?? []) {
|
||||
if (!_isScanSessionActive(scanSession)) {
|
||||
break;
|
||||
}
|
||||
if (localIps.contains(addr.address)) continue;
|
||||
final result =
|
||||
await _checkDevice(
|
||||
addr.address,
|
||||
service.port ?? scanPort,
|
||||
name: service.name,
|
||||
);
|
||||
if (result != null) {
|
||||
found.add(result);
|
||||
onServerDiscovered?.call(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await stopDiscovery(_activeDiscovery!);
|
||||
_activeDiscovery = null;
|
||||
debugPrint(
|
||||
'✅ [NetworkScanner] mDNS done. Found ${found.length} devices.');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NetworkScanner] mDNS failed: $e');
|
||||
if (_activeDiscovery != null) {
|
||||
try {
|
||||
await stopDiscovery(_activeDiscovery!);
|
||||
} catch (_) {}
|
||||
_activeDiscovery = null;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── Port scan fallback ─────────────────────────────────────────────────────
|
||||
|
||||
Future<List<DiscoveredServer>> _scanByPort({
|
||||
int? port,
|
||||
required int scanSession,
|
||||
}) async {
|
||||
final scanPort = port ?? defaultPort;
|
||||
final found = <DiscoveredServer>[];
|
||||
|
||||
final localIps = await _getLocalIpAddresses();
|
||||
final ips = await _getLocalNetworkRange();
|
||||
if (ips.isEmpty) return [];
|
||||
|
||||
debugPrint(
|
||||
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
|
||||
|
||||
int scanned = 0;
|
||||
for (int i = 0; i < ips.length && _isScanSessionActive(scanSession); i += parallelScans) {
|
||||
final batch = ips.skip(i).take(parallelScans).toList();
|
||||
final results =
|
||||
await Future.wait(
|
||||
batch.map((ip) => _checkDevice(ip, scanPort, name: ip)),
|
||||
);
|
||||
|
||||
for (final result in results) {
|
||||
if (result != null && !localIps.contains(result.ipAddress)) {
|
||||
found.add(result);
|
||||
onServerDiscovered?.call(result);
|
||||
}
|
||||
}
|
||||
|
||||
scanned += batch.length;
|
||||
onProgressUpdate?.call(scanned, ips.length);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Scan for MeshCore WiFi devices. Tries mDNS first, falls back to port scan.
|
||||
Future<List<DiscoveredServer>> scan({int? port}) async {
|
||||
if (_isScanning) return [];
|
||||
_isScanning = true;
|
||||
final scanSession = ++_scanSession;
|
||||
|
||||
try {
|
||||
var found = await _discoverViaMdns(port: port, scanSession: scanSession);
|
||||
if (_isScanSessionActive(scanSession) && found.isEmpty) {
|
||||
debugPrint(
|
||||
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
|
||||
found = await _scanByPort(port: port, scanSession: scanSession);
|
||||
}
|
||||
if (_isScanSessionActive(scanSession)) {
|
||||
_cachedServers = found;
|
||||
}
|
||||
return found;
|
||||
} finally {
|
||||
if (_scanSession == scanSession) {
|
||||
_isScanning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a previously discovered device is still reachable.
|
||||
Future<bool> verifyServer(DiscoveredServer server) async {
|
||||
final result = await _checkDevice(
|
||||
server.ipAddress,
|
||||
server.port,
|
||||
name: server.name,
|
||||
);
|
||||
return result != null;
|
||||
}
|
||||
|
||||
void clearCache() => _cachedServers = [];
|
||||
|
||||
bool _isScanSessionActive(int session) => _isScanning && _scanSession == session;
|
||||
|
||||
void stopScan() {
|
||||
_scanSession += 1;
|
||||
_isScanning = false;
|
||||
final activeDiscovery = _activeDiscovery;
|
||||
_activeDiscovery = null;
|
||||
if (activeDiscovery != null) {
|
||||
unawaited(
|
||||
stopDiscovery(activeDiscovery).catchError((Object error) {
|
||||
debugPrint('⚠️ [NetworkScanner] Failed to stop mDNS discovery: $error');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1000
lib/services/notification_service.dart
Normal file
1000
lib/services/notification_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
184
lib/services/offline_map_caching_provider.dart
Normal file
184
lib/services/offline_map_caching_provider.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import 'offline_tile_cache_service.dart';
|
||||
|
||||
/// A [MapCachingProvider] that checks the offline tile cache before
|
||||
/// falling through to the built-in cache/network path.
|
||||
///
|
||||
/// This allows preloaded tiles to be served during normal map browsing.
|
||||
class OfflineMapCachingProvider implements MapCachingProvider {
|
||||
final MapCachingProvider _delegate;
|
||||
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||
|
||||
OfflineMapCachingProvider(this._delegate);
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async {
|
||||
// Extract tile coordinates from URL to check our AVIF cache
|
||||
final coords = _parseTileUrl(url);
|
||||
if (coords != null) {
|
||||
final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url));
|
||||
|
||||
// Check local offline cache first
|
||||
final cachedTile = await _cache.getTileData(
|
||||
styleHash,
|
||||
coords.z,
|
||||
coords.x,
|
||||
coords.y,
|
||||
);
|
||||
if (cachedTile != null) {
|
||||
return (
|
||||
bytes: cachedTile.bytes,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.now().add(const Duration(days: 365)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to delegate (built-in cache)
|
||||
return _delegate.getTile(url);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
// Only delegate to built-in cache for normal browsing tiles
|
||||
return _delegate.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static ({int z, int x, int y})? parseTileUrlForTesting(String url) {
|
||||
final coords = _parseTileUrl(url);
|
||||
if (coords == null) {
|
||||
return null;
|
||||
}
|
||||
return (z: coords.z, x: coords.x, y: coords.y);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static String extractUrlTemplateForTesting(String url) {
|
||||
return _extractUrlTemplate(url);
|
||||
}
|
||||
|
||||
/// Parse z/x/y from a tile URL.
|
||||
static _TileCoords? _parseTileUrl(String url) {
|
||||
final queryStyleMatch = RegExp(
|
||||
r'(?:\?|&|/)(?:[^#]*&)?x=(\d+)&y=(\d+)&z=(\d+)(?:&|$)',
|
||||
).firstMatch(url);
|
||||
if (queryStyleMatch != null) {
|
||||
return _TileCoords(
|
||||
z: int.parse(queryStyleMatch.group(3)!),
|
||||
x: int.parse(queryStyleMatch.group(1)!),
|
||||
y: int.parse(queryStyleMatch.group(2)!),
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null) {
|
||||
final z = int.tryParse(uri.queryParameters['z'] ?? '');
|
||||
final x = int.tryParse(uri.queryParameters['x'] ?? '');
|
||||
final y = int.tryParse(uri.queryParameters['y'] ?? '');
|
||||
if (z != null && x != null && y != null) {
|
||||
return _TileCoords(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
|
||||
// Match known path formats.
|
||||
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
|
||||
final yxTileMatch = yxTilePattern.firstMatch(url);
|
||||
if (yxTileMatch != null) {
|
||||
return _TileCoords(
|
||||
z: int.parse(yxTileMatch.group(1)!),
|
||||
x: int.parse(yxTileMatch.group(3)!),
|
||||
y: int.parse(yxTileMatch.group(2)!),
|
||||
);
|
||||
}
|
||||
|
||||
final patterns = [
|
||||
RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'),
|
||||
RegExp(r'/(\d+)/(\d+)/(\d+)$'),
|
||||
];
|
||||
|
||||
for (final pattern in patterns) {
|
||||
final match = pattern.firstMatch(url);
|
||||
if (match != null) {
|
||||
return _TileCoords(
|
||||
z: int.parse(match.group(1)!),
|
||||
x: int.parse(match.group(2)!),
|
||||
y: int.parse(match.group(3)!),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extract a URL template from a concrete URL by replacing coordinates.
|
||||
static String _extractUrlTemplate(String url) {
|
||||
final queryStylePattern = RegExp(r'([?&])x=\d+&y=\d+&z=\d+');
|
||||
if (queryStylePattern.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
queryStylePattern,
|
||||
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
|
||||
);
|
||||
}
|
||||
|
||||
final pathStyleQueryPattern = RegExp(r'(&)x=\d+&y=\d+&z=\d+');
|
||||
if (pathStyleQueryPattern.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
pathStyleQueryPattern,
|
||||
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null) {
|
||||
final query = Map<String, String>.from(uri.queryParameters);
|
||||
var replacedQuery = false;
|
||||
if (query.containsKey('z')) {
|
||||
query['z'] = '{z}';
|
||||
replacedQuery = true;
|
||||
}
|
||||
if (query.containsKey('x')) {
|
||||
query['x'] = '{x}';
|
||||
replacedQuery = true;
|
||||
}
|
||||
if (query.containsKey('y')) {
|
||||
query['y'] = '{y}';
|
||||
replacedQuery = true;
|
||||
}
|
||||
if (replacedQuery) {
|
||||
return uri.replace(queryParameters: query).toString();
|
||||
}
|
||||
}
|
||||
|
||||
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
|
||||
final yxTileMatch = yxTilePattern.firstMatch(url);
|
||||
if (yxTileMatch != null) {
|
||||
return url.replaceRange(
|
||||
yxTileMatch.start,
|
||||
yxTileMatch.end,
|
||||
'/tile/{z}/{y}/{x}',
|
||||
);
|
||||
}
|
||||
|
||||
return url.replaceAllMapped(
|
||||
RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'),
|
||||
(m) => '/{z}/{x}/{y}${m.group(4) ?? ''}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileCoords {
|
||||
final int z, x, y;
|
||||
const _TileCoords({required this.z, required this.x, required this.y});
|
||||
}
|
||||
939
lib/services/offline_tile_cache_service.dart
Normal file
939
lib/services/offline_tile_cache_service.dart
Normal file
@@ -0,0 +1,939 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
/// A saved download region (polygons + zoom range) for quick re-download.
|
||||
class DownloadRegion {
|
||||
final List<List<List<double>>> polygons; // [polygon][vertex][lat, lng]
|
||||
final int minZoom;
|
||||
final int maxZoom;
|
||||
|
||||
const DownloadRegion({
|
||||
required this.polygons,
|
||||
required this.minZoom,
|
||||
required this.maxZoom,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'polygons': polygons,
|
||||
'minZoom': minZoom,
|
||||
'maxZoom': maxZoom,
|
||||
};
|
||||
|
||||
factory DownloadRegion.fromJson(Map<String, dynamic> json) {
|
||||
final rawPolygons = json['polygons'] as List<dynamic>? ?? [];
|
||||
final polygons = rawPolygons.map<List<List<double>>>((poly) {
|
||||
return (poly as List<dynamic>).map<List<double>>((vertex) {
|
||||
final v = vertex as List<dynamic>;
|
||||
return [
|
||||
(v[0] as num).toDouble(),
|
||||
(v[1] as num).toDouble(),
|
||||
];
|
||||
}).toList();
|
||||
}).toList();
|
||||
return DownloadRegion(
|
||||
polygons: polygons,
|
||||
minZoom: json['minZoom'] as int? ?? 8,
|
||||
maxZoom: json['maxZoom'] as int? ?? 14,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about a cached map style.
|
||||
class StyleInfo {
|
||||
final String hash;
|
||||
final String displayName;
|
||||
final String urlTemplate;
|
||||
final int tileCount;
|
||||
final int sizeBytes;
|
||||
final DownloadRegion? region;
|
||||
|
||||
const StyleInfo({
|
||||
required this.hash,
|
||||
required this.displayName,
|
||||
required this.urlTemplate,
|
||||
this.tileCount = 0,
|
||||
this.sizeBytes = 0,
|
||||
this.region,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'hash': hash,
|
||||
'displayName': displayName,
|
||||
'urlTemplate': urlTemplate,
|
||||
'tileCount': tileCount,
|
||||
'sizeBytes': sizeBytes,
|
||||
if (region != null) 'region': region!.toJson(),
|
||||
};
|
||||
|
||||
factory StyleInfo.fromJson(Map<String, dynamic> json) => StyleInfo(
|
||||
hash: json['hash'] as String,
|
||||
displayName: json['displayName'] as String? ?? json['hash'] as String,
|
||||
urlTemplate: json['urlTemplate'] as String? ?? '',
|
||||
tileCount: json['tileCount'] as int? ?? 0,
|
||||
sizeBytes: json['sizeBytes'] as int? ?? 0,
|
||||
region: json['region'] != null
|
||||
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// A tile coordinate in the cache (z/x/y).
|
||||
class CachedTileCoord {
|
||||
final int z, x, y;
|
||||
const CachedTileCoord(this.z, this.x, this.y);
|
||||
|
||||
Map<String, int> toJson() => {'z': z, 'x': x, 'y': y};
|
||||
|
||||
factory CachedTileCoord.fromJson(Map<String, dynamic> json) =>
|
||||
CachedTileCoord(
|
||||
json['z'] as int,
|
||||
json['x'] as int,
|
||||
json['y'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
class CachedTileData {
|
||||
final Uint8List bytes;
|
||||
final String? contentType;
|
||||
|
||||
const CachedTileData({
|
||||
required this.bytes,
|
||||
required this.contentType,
|
||||
});
|
||||
}
|
||||
|
||||
class _CachedTileLocation {
|
||||
final String relativePath;
|
||||
final String? contentType;
|
||||
|
||||
const _CachedTileLocation({
|
||||
required this.relativePath,
|
||||
required this.contentType,
|
||||
});
|
||||
}
|
||||
|
||||
/// Manages the offline tile cache on disk with a SQLite metadata index.
|
||||
class OfflineTileCacheService {
|
||||
OfflineTileCacheService._();
|
||||
static final instance = OfflineTileCacheService._();
|
||||
|
||||
static const int _memoryCacheMaxEntries = 4096;
|
||||
static const int _memoryCacheMaxBytes = 64 * 1024 * 1024;
|
||||
|
||||
String? _baseDir;
|
||||
String? _databasePath;
|
||||
Database? _database;
|
||||
int _tileMemoryBytes = 0;
|
||||
|
||||
final Map<String, Set<String>> _manifests = {};
|
||||
final Map<String, Future<void>> _styleHydrations = {};
|
||||
final Map<String, _CachedTileLocation> _tileLocationCache = {};
|
||||
final LinkedHashMap<String, CachedTileData> _tileMemoryCache =
|
||||
LinkedHashMap<String, CachedTileData>();
|
||||
|
||||
Future<String> get baseDir async {
|
||||
if (_baseDir != null) return _baseDir!;
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
_baseDir = '${docs.path}/offline_tiles';
|
||||
return _baseDir!;
|
||||
}
|
||||
|
||||
Future<String> get _dbPath async {
|
||||
if (_databasePath != null) return _databasePath!;
|
||||
final docs = await getApplicationSupportDirectory();
|
||||
_databasePath = '${docs.path}/offline_tiles.db';
|
||||
return _databasePath!;
|
||||
}
|
||||
|
||||
Future<Database> get _db async {
|
||||
if (_database != null) return _database!;
|
||||
final path = await _dbPath;
|
||||
_database = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await _createSchema(db);
|
||||
},
|
||||
);
|
||||
return _database!;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void setBaseDirForTesting(String path) {
|
||||
_baseDir = path;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void setDatabasePathForTesting(String path) {
|
||||
_databasePath = path;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> resetForTesting() async {
|
||||
await _database?.close();
|
||||
_database = null;
|
||||
_baseDir = null;
|
||||
_databasePath = null;
|
||||
_manifests.clear();
|
||||
clearMemoryCacheForTesting();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void clearMemoryCacheForTesting() {
|
||||
_tileLocationCache.clear();
|
||||
_tileMemoryCache.clear();
|
||||
_tileMemoryBytes = 0;
|
||||
}
|
||||
|
||||
/// Derive a short deterministic hash from a URL template.
|
||||
String styleHashFromUrl(String urlTemplate) {
|
||||
final bytes = sha256.convert(urlTemplate.codeUnits).bytes;
|
||||
return bytes
|
||||
.take(6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
|
||||
static String _tileKey(int z, int x, int y) => '$z/$x/$y';
|
||||
|
||||
String _tileDir(String base, String styleHash, int z, int x) {
|
||||
return '$base/$styleHash/$z/$x';
|
||||
}
|
||||
|
||||
String _manifestPath(String base, String styleHash) =>
|
||||
'$base/$styleHash/manifest.txt';
|
||||
|
||||
Future<void> _createSchema(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS offline_tile_styles (
|
||||
style_hash TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
url_template TEXT NOT NULL,
|
||||
region_json TEXT,
|
||||
tile_count INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS offline_tile_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
style_hash TEXT NOT NULL,
|
||||
z INTEGER NOT NULL,
|
||||
x INTEGER NOT NULL,
|
||||
y INTEGER NOT NULL,
|
||||
relative_path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
cached_at INTEGER NOT NULL,
|
||||
UNIQUE(style_hash, z, x, y)
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS offline_tile_entries_style_idx '
|
||||
'ON offline_tile_entries(style_hash)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Load the manifest for a style into memory (if not already loaded).
|
||||
Future<Set<String>> loadManifest(String styleHash) async {
|
||||
if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!;
|
||||
|
||||
var rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
columns: ['z', 'x', 'y'],
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _hydrateStyleFromDisk(styleHash);
|
||||
rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
columns: ['z', 'x', 'y'],
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
);
|
||||
}
|
||||
|
||||
final manifest = rows
|
||||
.map((row) => _tileKey(
|
||||
row['z'] as int,
|
||||
row['x'] as int,
|
||||
row['y'] as int,
|
||||
))
|
||||
.toSet();
|
||||
_manifests[styleHash] = manifest;
|
||||
return manifest;
|
||||
}
|
||||
|
||||
Future<CachedTileData?> getTileData(
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
) async {
|
||||
final cacheKey = '$styleHash/${_tileKey(z, x, y)}';
|
||||
final cached = _tileMemoryCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_tileMemoryCache[cacheKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
var location = _tileLocationCache[cacheKey];
|
||||
if (location == null) {
|
||||
final row = await _loadTileRow(styleHash, z, x, y);
|
||||
if (row == null) return null;
|
||||
location = _CachedTileLocation(
|
||||
relativePath: row['relative_path'] as String,
|
||||
contentType: row['content_type'] as String?,
|
||||
);
|
||||
_tileLocationCache[cacheKey] = location;
|
||||
}
|
||||
|
||||
final base = await baseDir;
|
||||
final file = File('$base/${location.relativePath}');
|
||||
if (!await file.exists()) {
|
||||
await _deleteTileEntry(styleHash, z, x, y);
|
||||
return null;
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
try {
|
||||
bytes = await file.readAsBytes();
|
||||
} on FileSystemException {
|
||||
await _deleteTileEntry(styleHash, z, x, y);
|
||||
return null;
|
||||
}
|
||||
final data = CachedTileData(
|
||||
bytes: bytes,
|
||||
contentType: location.contentType,
|
||||
);
|
||||
_rememberTile(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Read a cached tile's raw bytes (for serving to peers).
|
||||
Future<Uint8List?> getRawTile(String styleHash, int z, int x, int y) async {
|
||||
final tile = await getTileData(styleHash, z, x, y);
|
||||
return tile?.bytes;
|
||||
}
|
||||
|
||||
/// Check if a tile exists in the cache.
|
||||
Future<bool> hasTile(String styleHash, int z, int x, int y) async {
|
||||
final cacheKey = '$styleHash/${_tileKey(z, x, y)}';
|
||||
if (_tileLocationCache.containsKey(cacheKey)) {
|
||||
_manifests[styleHash] ??= {};
|
||||
_manifests[styleHash]!.add(_tileKey(z, x, y));
|
||||
return true;
|
||||
}
|
||||
|
||||
final row = await _loadTileRow(styleHash, z, x, y);
|
||||
if (row != null) {
|
||||
_tileLocationCache[cacheKey] = _CachedTileLocation(
|
||||
relativePath: row['relative_path'] as String,
|
||||
contentType: row['content_type'] as String?,
|
||||
);
|
||||
_manifests[styleHash] ??= {};
|
||||
_manifests[styleHash]!.add(_tileKey(z, x, y));
|
||||
return true;
|
||||
}
|
||||
|
||||
final manifest = await loadManifest(styleHash);
|
||||
return manifest.contains(_tileKey(z, x, y));
|
||||
}
|
||||
|
||||
/// Store a tile's original bytes and update the SQLite index.
|
||||
Future<void> putTile(
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
Uint8List bytes, {
|
||||
String? contentType,
|
||||
String? sourceUrl,
|
||||
}) async {
|
||||
final resolvedContentType = _normalizeContentType(contentType);
|
||||
final fileExtension = _fileExtensionForTile(
|
||||
contentType: resolvedContentType,
|
||||
sourceUrl: sourceUrl,
|
||||
);
|
||||
final relativePath = '$styleHash/$z/$x/$y.$fileExtension';
|
||||
final base = await baseDir;
|
||||
final dir = Directory(_tileDir(base, styleHash, z, x));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File('$base/$relativePath');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
await _upsertTileEntry(
|
||||
styleHash: styleHash,
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
relativePath: relativePath,
|
||||
contentType: resolvedContentType,
|
||||
sizeBytes: bytes.length,
|
||||
);
|
||||
|
||||
_manifests[styleHash] ??= {};
|
||||
_manifests[styleHash]!.add(_tileKey(z, x, y));
|
||||
await _writeManifest(base, styleHash, _manifests[styleHash]!);
|
||||
_rememberTile(
|
||||
'$styleHash/${_tileKey(z, x, y)}',
|
||||
CachedTileData(bytes: bytes, contentType: resolvedContentType),
|
||||
);
|
||||
}
|
||||
|
||||
/// Store raw tile bytes directly (from a peer), update the SQLite index.
|
||||
Future<void> putRawTile(
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
Uint8List bytes, {
|
||||
String? contentType,
|
||||
}) async {
|
||||
await putTile(
|
||||
styleHash,
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
bytes,
|
||||
contentType: contentType,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get total cache size in bytes.
|
||||
Future<int> getCacheSize() async {
|
||||
await _hydrateAllStylesIfDatabaseIsEmpty();
|
||||
final rows = await (await _db).rawQuery(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM offline_tile_entries',
|
||||
);
|
||||
return (rows.first['total'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
/// List all style hashes that have cached tiles.
|
||||
Future<List<String>> listStyles() async {
|
||||
final styles = await listStylesDetailed();
|
||||
return styles.map((style) => style.hash).toList();
|
||||
}
|
||||
|
||||
/// Save metadata for a style (name, URL template, download region).
|
||||
Future<void> saveStyleMeta(
|
||||
String styleHash, {
|
||||
required String displayName,
|
||||
required String urlTemplate,
|
||||
DownloadRegion? region,
|
||||
}) async {
|
||||
final base = await baseDir;
|
||||
final dir = Directory('$base/$styleHash');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final metaFile = File('$base/$styleHash/meta.json');
|
||||
final existing = await getStyleMeta(styleHash);
|
||||
final regionJson = region != null
|
||||
? jsonEncode(region.toJson())
|
||||
: existing?.region == null
|
||||
? null
|
||||
: jsonEncode(existing!.region!.toJson());
|
||||
final meta = <String, dynamic>{
|
||||
'displayName': displayName,
|
||||
'urlTemplate': urlTemplate,
|
||||
if (regionJson != null) 'region': jsonDecode(regionJson),
|
||||
};
|
||||
await metaFile.writeAsString(jsonEncode(meta), flush: true);
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await (await _db).insert(
|
||||
'offline_tile_styles',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'display_name': displayName,
|
||||
'url_template': urlTemplate,
|
||||
'region_json': regionJson,
|
||||
'tile_count': existing?.tileCount ?? 0,
|
||||
'size_bytes': existing?.sizeBytes ?? 0,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Read metadata for a style.
|
||||
Future<StyleInfo?> getStyleMeta(String styleHash) async {
|
||||
final rows = await (await _db).query(
|
||||
'offline_tile_styles',
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isNotEmpty) {
|
||||
return _styleInfoFromRow(rows.first);
|
||||
}
|
||||
|
||||
final base = await baseDir;
|
||||
final metaFile = File('$base/$styleHash/meta.json');
|
||||
if (!await metaFile.exists()) return null;
|
||||
try {
|
||||
final json = jsonDecode(await metaFile.readAsString());
|
||||
return StyleInfo(
|
||||
hash: styleHash,
|
||||
displayName: json['displayName'] as String? ?? styleHash,
|
||||
urlTemplate: json['urlTemplate'] as String? ?? '',
|
||||
region: json['region'] != null
|
||||
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// List all styles with metadata, tile counts, and sizes.
|
||||
Future<List<StyleInfo>> listStylesDetailed() async {
|
||||
await _hydrateAllStylesIfDatabaseIsEmpty();
|
||||
final rows = await (await _db).query(
|
||||
'offline_tile_styles',
|
||||
orderBy: 'updated_at DESC',
|
||||
);
|
||||
return rows.map(_styleInfoFromRow).toList();
|
||||
}
|
||||
|
||||
/// List all tile coordinates cached for a given style.
|
||||
Future<List<CachedTileCoord>> listTilesForStyle(String styleHash) async {
|
||||
var rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
columns: ['z', 'x', 'y'],
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
orderBy: 'z ASC, x ASC, y ASC',
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
await _hydrateStyleFromDisk(styleHash);
|
||||
rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
columns: ['z', 'x', 'y'],
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
orderBy: 'z ASC, x ASC, y ASC',
|
||||
);
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row) => CachedTileCoord(
|
||||
row['z'] as int,
|
||||
row['x'] as int,
|
||||
row['y'] as int,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Delete a single style's tiles, manifest, and metadata.
|
||||
Future<void> deleteStyle(String styleHash) async {
|
||||
_manifests.remove(styleHash);
|
||||
_removeStyleFromMemory(styleHash);
|
||||
final base = await baseDir;
|
||||
final dir = Directory('$base/$styleHash');
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
await (await _db).delete(
|
||||
'offline_tile_entries',
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
);
|
||||
await (await _db).delete(
|
||||
'offline_tile_styles',
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete all cached tiles, manifests, and metadata.
|
||||
Future<void> clearCache() async {
|
||||
_manifests.clear();
|
||||
clearMemoryCacheForTesting();
|
||||
final base = await baseDir;
|
||||
final dir = Directory(base);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
await (await _db).delete('offline_tile_entries');
|
||||
await (await _db).delete('offline_tile_styles');
|
||||
}
|
||||
|
||||
StyleInfo _styleInfoFromRow(Map<String, Object?> row) {
|
||||
final regionJson = row['region_json'] as String?;
|
||||
return StyleInfo(
|
||||
hash: row['style_hash'] as String,
|
||||
displayName: row['display_name'] as String,
|
||||
urlTemplate: row['url_template'] as String,
|
||||
tileCount: row['tile_count'] as int? ?? 0,
|
||||
sizeBytes: row['size_bytes'] as int? ?? 0,
|
||||
region: regionJson == null
|
||||
? null
|
||||
: DownloadRegion.fromJson(jsonDecode(regionJson) as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _hydrateAllStylesIfDatabaseIsEmpty() async {
|
||||
final rows = await (await _db).rawQuery(
|
||||
'SELECT COUNT(*) AS count FROM offline_tile_styles',
|
||||
);
|
||||
if (((rows.first['count'] as int?) ?? 0) > 0) return;
|
||||
await _hydrateAllStylesFromDisk();
|
||||
}
|
||||
|
||||
Future<void> _hydrateAllStylesFromDisk() async {
|
||||
final base = await baseDir;
|
||||
final dir = Directory(base);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is Directory) {
|
||||
final styleHash = entity.path.split('/').last;
|
||||
await _hydrateStyleFromDisk(styleHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _hydrateStyleFromDisk(String styleHash) {
|
||||
final existing = _styleHydrations[styleHash];
|
||||
if (existing != null) return existing;
|
||||
|
||||
final hydration = _hydrateStyleFromDiskLocked(styleHash);
|
||||
_styleHydrations[styleHash] = hydration;
|
||||
return hydration.whenComplete(() {
|
||||
_styleHydrations.remove(styleHash);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _hydrateStyleFromDiskLocked(String styleHash) async {
|
||||
final base = await baseDir;
|
||||
final styleDir = Directory('$base/$styleHash');
|
||||
if (!await styleDir.exists()) return;
|
||||
|
||||
String displayName = styleHash;
|
||||
String urlTemplate = '';
|
||||
DownloadRegion? region;
|
||||
final metaFile = File('$base/$styleHash/meta.json');
|
||||
if (await metaFile.exists()) {
|
||||
try {
|
||||
final json = jsonDecode(await metaFile.readAsString());
|
||||
displayName = json['displayName'] as String? ?? styleHash;
|
||||
urlTemplate = json['urlTemplate'] as String? ?? '';
|
||||
if (json['region'] != null) {
|
||||
region = DownloadRegion.fromJson(json['region'] as Map<String, dynamic>);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final entries = <({int z, int x, int y, String relativePath, String? contentType, int sizeBytes})>[];
|
||||
await for (final entity in styleDir.list(recursive: true)) {
|
||||
if (entity is! File) continue;
|
||||
final relativePath = entity.path.replaceFirst('$base/', '');
|
||||
final segments = relativePath.split('/');
|
||||
if (segments.length != 4) continue;
|
||||
if (segments[3] == 'meta.json' || segments[3] == 'manifest.txt') continue;
|
||||
|
||||
final z = int.tryParse(segments[1]);
|
||||
final x = int.tryParse(segments[2]);
|
||||
final ySegment = segments[3].split('.').first;
|
||||
final y = int.tryParse(ySegment);
|
||||
if (z == null || x == null || y == null) continue;
|
||||
|
||||
final int sizeBytes;
|
||||
try {
|
||||
if (!await entity.exists()) continue;
|
||||
sizeBytes = await entity.length();
|
||||
} on FileSystemException {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.add((
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
relativePath: relativePath,
|
||||
contentType: _contentTypeFromPath(relativePath),
|
||||
sizeBytes: sizeBytes,
|
||||
));
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final totalSize = entries.fold<int>(
|
||||
0,
|
||||
(total, entry) => total + entry.sizeBytes,
|
||||
);
|
||||
await (await _db).transaction((txn) async {
|
||||
await txn.delete(
|
||||
'offline_tile_entries',
|
||||
where: 'style_hash = ?',
|
||||
whereArgs: [styleHash],
|
||||
);
|
||||
await txn.insert(
|
||||
'offline_tile_styles',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'display_name': displayName,
|
||||
'url_template': urlTemplate,
|
||||
'region_json': region == null ? null : jsonEncode(region.toJson()),
|
||||
'tile_count': entries.length,
|
||||
'size_bytes': totalSize,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
for (final entry in entries) {
|
||||
await txn.insert(
|
||||
'offline_tile_entries',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'z': entry.z,
|
||||
'x': entry.x,
|
||||
'y': entry.y,
|
||||
'relative_path': entry.relativePath,
|
||||
'content_type': entry.contentType,
|
||||
'size_bytes': entry.sizeBytes,
|
||||
'cached_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_manifests[styleHash] =
|
||||
entries.map((entry) => _tileKey(entry.z, entry.x, entry.y)).toSet();
|
||||
for (final entry in entries) {
|
||||
_tileLocationCache['$styleHash/${_tileKey(entry.z, entry.x, entry.y)}'] =
|
||||
_CachedTileLocation(
|
||||
relativePath: entry.relativePath,
|
||||
contentType: entry.contentType,
|
||||
);
|
||||
}
|
||||
await _writeManifest(base, styleHash, _manifests[styleHash]!);
|
||||
}
|
||||
|
||||
Future<void> _upsertTileEntry({
|
||||
required String styleHash,
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String relativePath,
|
||||
required String? contentType,
|
||||
required int sizeBytes,
|
||||
}) async {
|
||||
final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false);
|
||||
final existingStyle = await getStyleMeta(styleHash);
|
||||
if (existingTile != null &&
|
||||
existingTile['relative_path'] != null &&
|
||||
existingTile['relative_path'] != relativePath) {
|
||||
final base = await baseDir;
|
||||
final oldFile = File('$base/${existingTile['relative_path']}');
|
||||
if (await oldFile.exists()) {
|
||||
await oldFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
final tileCountDelta = existingTile == null ? 1 : 0;
|
||||
final sizeDelta = sizeBytes - ((existingTile?['size_bytes'] as int?) ?? 0);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await (await _db).transaction((txn) async {
|
||||
await txn.insert(
|
||||
'offline_tile_entries',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'z': z,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'relative_path': relativePath,
|
||||
'content_type': contentType,
|
||||
'size_bytes': sizeBytes,
|
||||
'cached_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await txn.insert(
|
||||
'offline_tile_styles',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'display_name': existingStyle?.displayName ?? styleHash,
|
||||
'url_template': existingStyle?.urlTemplate ?? '',
|
||||
'region_json': existingStyle?.region == null
|
||||
? null
|
||||
: jsonEncode(existingStyle!.region!.toJson()),
|
||||
'tile_count': (existingStyle?.tileCount ?? 0) + tileCountDelta,
|
||||
'size_bytes': (existingStyle?.sizeBytes ?? 0) + sizeDelta,
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
});
|
||||
|
||||
_tileLocationCache['$styleHash/${_tileKey(z, x, y)}'] =
|
||||
_CachedTileLocation(
|
||||
relativePath: relativePath,
|
||||
contentType: contentType,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteTileEntry(String styleHash, int z, int x, int y) async {
|
||||
final existingTile = await _loadTileRow(styleHash, z, x, y, hydrate: false);
|
||||
if (existingTile == null) return;
|
||||
|
||||
final existingStyle = await getStyleMeta(styleHash);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await (await _db).transaction((txn) async {
|
||||
await txn.delete(
|
||||
'offline_tile_entries',
|
||||
where: 'style_hash = ? AND z = ? AND x = ? AND y = ?',
|
||||
whereArgs: [styleHash, z, x, y],
|
||||
);
|
||||
if (existingStyle != null) {
|
||||
await txn.insert(
|
||||
'offline_tile_styles',
|
||||
{
|
||||
'style_hash': styleHash,
|
||||
'display_name': existingStyle.displayName,
|
||||
'url_template': existingStyle.urlTemplate,
|
||||
'region_json': existingStyle.region == null
|
||||
? null
|
||||
: jsonEncode(existingStyle.region!.toJson()),
|
||||
'tile_count': (existingStyle.tileCount - 1).clamp(0, existingStyle.tileCount),
|
||||
'size_bytes': (existingStyle.sizeBytes - (existingTile['size_bytes'] as int))
|
||||
.clamp(0, existingStyle.sizeBytes),
|
||||
'updated_at': now,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_manifests[styleHash]?.remove(_tileKey(z, x, y));
|
||||
_tileLocationCache.remove('$styleHash/${_tileKey(z, x, y)}');
|
||||
final removed = _tileMemoryCache.remove('$styleHash/${_tileKey(z, x, y)}');
|
||||
if (removed != null) {
|
||||
_tileMemoryBytes -= removed.bytes.length;
|
||||
}
|
||||
final base = await baseDir;
|
||||
final manifest = _manifests[styleHash];
|
||||
if (manifest != null) {
|
||||
await _writeManifest(base, styleHash, manifest);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeManifest(
|
||||
String base, String styleHash, Set<String> manifest) async {
|
||||
final dir = Directory('$base/$styleHash');
|
||||
if (!await dir.exists()) await dir.create(recursive: true);
|
||||
await File(_manifestPath(base, styleHash))
|
||||
.writeAsString(manifest.join('\n'), flush: true);
|
||||
}
|
||||
|
||||
void _rememberTile(String key, CachedTileData data) {
|
||||
final existing = _tileMemoryCache.remove(key);
|
||||
if (existing != null) {
|
||||
_tileMemoryBytes -= existing.bytes.length;
|
||||
}
|
||||
_tileMemoryCache[key] = data;
|
||||
_tileMemoryBytes += data.bytes.length;
|
||||
|
||||
while (_tileMemoryCache.length > _memoryCacheMaxEntries ||
|
||||
_tileMemoryBytes > _memoryCacheMaxBytes) {
|
||||
final firstKey = _tileMemoryCache.keys.first;
|
||||
final removed = _tileMemoryCache.remove(firstKey);
|
||||
if (removed != null) {
|
||||
_tileMemoryBytes -= removed.bytes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeStyleFromMemory(String styleHash) {
|
||||
final prefix = '$styleHash/';
|
||||
_tileLocationCache.removeWhere((key, _) => key.startsWith(prefix));
|
||||
final keys = _tileMemoryCache.keys.where((key) => key.startsWith(prefix)).toList();
|
||||
for (final key in keys) {
|
||||
final removed = _tileMemoryCache.remove(key);
|
||||
if (removed != null) {
|
||||
_tileMemoryBytes -= removed.bytes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _normalizeContentType(String? contentType) {
|
||||
if (contentType == null || contentType.isEmpty) return null;
|
||||
return contentType.split(';').first.trim().toLowerCase();
|
||||
}
|
||||
|
||||
String _fileExtensionForTile({
|
||||
required String? contentType,
|
||||
required String? sourceUrl,
|
||||
}) {
|
||||
if (contentType != null) {
|
||||
switch (contentType) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/avif':
|
||||
return 'avif';
|
||||
}
|
||||
}
|
||||
|
||||
final uri = sourceUrl == null ? null : Uri.tryParse(sourceUrl);
|
||||
final segment = uri?.pathSegments.isNotEmpty == true
|
||||
? uri!.pathSegments.last
|
||||
: '';
|
||||
final match = RegExp(r'\.(png|jpg|jpeg|webp|avif)$', caseSensitive: false)
|
||||
.firstMatch(segment);
|
||||
final extension = match?.group(1)?.toLowerCase();
|
||||
if (extension == 'jpeg') return 'jpg';
|
||||
return extension ?? 'tile';
|
||||
}
|
||||
|
||||
String? _contentTypeFromPath(String relativePath) {
|
||||
if (relativePath.endsWith('.png')) return 'image/png';
|
||||
if (relativePath.endsWith('.jpg') || relativePath.endsWith('.jpeg')) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (relativePath.endsWith('.webp')) return 'image/webp';
|
||||
if (relativePath.endsWith('.avif')) return 'image/avif';
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> _loadTileRow(
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y, {
|
||||
bool hydrate = true,
|
||||
}) async {
|
||||
var rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
where: 'style_hash = ? AND z = ? AND x = ? AND y = ?',
|
||||
whereArgs: [styleHash, z, x, y],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty && hydrate) {
|
||||
await _hydrateStyleFromDisk(styleHash);
|
||||
rows = await (await _db).query(
|
||||
'offline_tile_entries',
|
||||
where: 'style_hash = ? AND z = ? AND x = ? AND y = ?',
|
||||
whereArgs: [styleHash, z, x, y],
|
||||
limit: 1,
|
||||
);
|
||||
}
|
||||
return rows.isEmpty ? null : rows.first;
|
||||
}
|
||||
}
|
||||
153
lib/services/packet_capture_storage_service.dart
Normal file
153
lib/services/packet_capture_storage_service.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/ble_packet_log.dart';
|
||||
|
||||
class StoredPacketCapture {
|
||||
final DateTime timestamp;
|
||||
final String direction;
|
||||
final int? responseCode;
|
||||
final String? description;
|
||||
final String rawBase64;
|
||||
final int rawSize;
|
||||
final double? snrDb;
|
||||
final int? rssiDbm;
|
||||
|
||||
const StoredPacketCapture({
|
||||
required this.timestamp,
|
||||
required this.direction,
|
||||
required this.responseCode,
|
||||
required this.description,
|
||||
required this.rawBase64,
|
||||
required this.rawSize,
|
||||
required this.snrDb,
|
||||
required this.rssiDbm,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'ts': timestamp.millisecondsSinceEpoch,
|
||||
'dir': direction,
|
||||
'code': responseCode,
|
||||
'desc': description,
|
||||
'raw': rawBase64,
|
||||
'size': rawSize,
|
||||
'snr': snrDb,
|
||||
'rssi': rssiDbm,
|
||||
};
|
||||
}
|
||||
|
||||
static StoredPacketCapture fromJson(Map<String, dynamic> json) {
|
||||
return StoredPacketCapture(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch((json['ts'] as num).toInt()),
|
||||
direction: (json['dir'] as String?) ?? 'rx',
|
||||
responseCode: (json['code'] as num?)?.toInt(),
|
||||
description: json['desc'] as String?,
|
||||
rawBase64: (json['raw'] as String?) ?? '',
|
||||
rawSize: (json['size'] as num?)?.toInt() ?? 0,
|
||||
snrDb: (json['snr'] as num?)?.toDouble(),
|
||||
rssiDbm: (json['rssi'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable storage for raw BLE packets so future features can consume
|
||||
/// historical packet bytes across app restarts.
|
||||
class PacketCaptureStorageService {
|
||||
static const String _fileName = 'packet_captures.jsonl';
|
||||
static const int _maxStoredPackets = 20000;
|
||||
|
||||
File? _file;
|
||||
|
||||
Future<File> _resolveFile() async {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||
}
|
||||
if (_file != null) return _file!;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File('${dir.path}/$_fileName');
|
||||
if (!await file.exists()) {
|
||||
await file.create(recursive: true);
|
||||
}
|
||||
_file = file;
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||
if (logs.isEmpty) return;
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
final sink = file.openWrite(mode: FileMode.append);
|
||||
for (final log in logs) {
|
||||
final row = StoredPacketCapture(
|
||||
timestamp: log.timestamp,
|
||||
direction: log.direction.name,
|
||||
responseCode: log.responseCode,
|
||||
description: log.description,
|
||||
rawBase64: base64Encode(log.rawData),
|
||||
rawSize: log.rawData.length,
|
||||
snrDb: log.logRxDataInfo?.snrDb,
|
||||
rssiDbm: log.logRxDataInfo?.rssiDbm,
|
||||
);
|
||||
sink.writeln(jsonEncode(row.toJson()));
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
await _pruneIfNeeded(file);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [PacketCaptureStorage] Failed to append logs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||
if (kIsWeb) return const [];
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return const [];
|
||||
final lines = await file.readAsLines();
|
||||
if (lines.isEmpty) return const [];
|
||||
final start = lines.length > limit ? lines.length - limit : 0;
|
||||
return lines
|
||||
.sublist(start)
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.map((l) => StoredPacketCapture.fromJson(jsonDecode(l) as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [PacketCaptureStorage] Failed to load recent logs: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
if (kIsWeb) return 0;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return 0;
|
||||
final lines = await file.readAsLines();
|
||||
return lines.where((l) => l.trim().isNotEmpty).length;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (await file.exists()) {
|
||||
await file.writeAsString('');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [PacketCaptureStorage] Failed to clear logs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pruneIfNeeded(File file) async {
|
||||
final lines = await file.readAsLines();
|
||||
if (lines.length <= _maxStoredPackets) return;
|
||||
final keep = lines.sublist(lines.length - _maxStoredPackets);
|
||||
await file.writeAsString('${keep.join('\n')}\n');
|
||||
}
|
||||
}
|
||||
148
lib/services/path_history_service.dart
Normal file
148
lib/services/path_history_service.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
|
||||
class _ManualPathSelectionRecord {
|
||||
final List<int> pathBytes;
|
||||
final int hopCount;
|
||||
final int hashSize;
|
||||
|
||||
const _ManualPathSelectionRecord({
|
||||
required this.pathBytes,
|
||||
required this.hopCount,
|
||||
required this.hashSize,
|
||||
});
|
||||
|
||||
factory _ManualPathSelectionRecord.fromJson(Map<String, dynamic> json) {
|
||||
final pathBytes = (json['pathBytes'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
return _ManualPathSelectionRecord(
|
||||
pathBytes: pathBytes,
|
||||
hopCount: json['hopCount'] as int? ?? 0,
|
||||
hashSize: json['hashSize'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'pathBytes': pathBytes,
|
||||
'hopCount': hopCount,
|
||||
'hashSize': hashSize,
|
||||
};
|
||||
|
||||
PathSelection toSelection() => PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList(pathBytes),
|
||||
hopCount: hopCount,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
}
|
||||
|
||||
class PathHistoryService {
|
||||
static const String _legacyStorageKey = 'contact_path_history_v2';
|
||||
static const String _manualRouteStorageKey =
|
||||
'contact_manual_path_overrides_v1';
|
||||
|
||||
final Map<String, _ManualPathSelectionRecord> _manualSelections = {};
|
||||
bool _isLoaded = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isLoaded) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_legacyStorageKey);
|
||||
|
||||
final manualRaw = prefs.getString(_manualRouteStorageKey);
|
||||
try {
|
||||
if (manualRaw != null && manualRaw.isNotEmpty) {
|
||||
final decoded = jsonDecode(manualRaw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
_manualSelections[entry.key] =
|
||||
_ManualPathSelectionRecord.fromJson(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'⚠️ [PathHistoryService] Failed to load manual routes: $error',
|
||||
);
|
||||
}
|
||||
|
||||
_isLoaded = true;
|
||||
}
|
||||
|
||||
Future<PathSelection> getSelectionForContact(Contact contact) async {
|
||||
await initialize();
|
||||
final manualSelection = _manualSelections[contact.publicKeyHex];
|
||||
if (manualSelection != null) {
|
||||
return manualSelection.toSelection();
|
||||
}
|
||||
|
||||
final route = ContactRouteCodec.fromContact(contact);
|
||||
if (route == null) {
|
||||
return PathSelection.flood();
|
||||
}
|
||||
|
||||
return PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList(route.pathBytes),
|
||||
hopCount: route.hopCount,
|
||||
hashSize: route.hashSize,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setManualRouteForContact(
|
||||
Contact contact,
|
||||
ParsedContactRoute route,
|
||||
) async {
|
||||
await setManualSelectionFor(
|
||||
contact.publicKeyHex,
|
||||
PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList(route.pathBytes),
|
||||
hopCount: route.hopCount,
|
||||
hashSize: route.hashSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setManualSelectionFor(
|
||||
String contactPublicKeyHex,
|
||||
PathSelection selection,
|
||||
) async {
|
||||
await initialize();
|
||||
_manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord(
|
||||
pathBytes: selection.pathBytes.toList(),
|
||||
hopCount: selection.hopCount,
|
||||
hashSize: selection.hashSize,
|
||||
);
|
||||
await _persistManualSelections();
|
||||
}
|
||||
|
||||
Future<PathSelection?> getManualSelectionForContact(Contact contact) async {
|
||||
await initialize();
|
||||
return _manualSelections[contact.publicKeyHex]?.toSelection();
|
||||
}
|
||||
|
||||
Future<void> clearManualRouteFor(String contactPublicKeyHex) async {
|
||||
await initialize();
|
||||
_manualSelections.remove(contactPublicKeyHex);
|
||||
await _persistManualSelections();
|
||||
}
|
||||
|
||||
Future<void> _persistManualSelections() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final manualPayload = <String, dynamic>{};
|
||||
for (final entry in _manualSelections.entries) {
|
||||
manualPayload[entry.key] = entry.value.toJson();
|
||||
}
|
||||
await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload));
|
||||
}
|
||||
}
|
||||
21
lib/services/profile_device_key_resolver.dart
Normal file
21
lib/services/profile_device_key_resolver.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import '../models/device_info.dart';
|
||||
|
||||
class ProfileDeviceKeyResolver {
|
||||
static String? resolve({
|
||||
required DeviceInfo deviceInfo,
|
||||
required ConnectionMode connectionMode,
|
||||
}) {
|
||||
// Always prefer the public key — it's the only truly stable identifier.
|
||||
// Don't fall back to deviceId to avoid creating duplicate profiles when
|
||||
// publicKey arrives late (after initial connection but before DeviceInfo).
|
||||
final publicKey = deviceInfo.publicKey;
|
||||
if (publicKey == null || publicKey.isEmpty) {
|
||||
return null; // Wait until publicKey is available
|
||||
}
|
||||
|
||||
final hex = publicKey
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return hex.isNotEmpty ? 'pk:$hex' : null;
|
||||
}
|
||||
}
|
||||
185
lib/services/profile_manager.dart
Normal file
185
lib/services/profile_manager.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
class ProfileManager with ChangeNotifier {
|
||||
static const String _profilesKey = 'profiles_library';
|
||||
static const String activeProfileIdKey = 'profiles_active_profile_id';
|
||||
static const String _deviceProfileDefaultsKey =
|
||||
'profiles_device_active_profile_ids';
|
||||
static const String _transferHistoryKey = 'profiles_transfer_history';
|
||||
|
||||
final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
|
||||
final List<ProfileTransferRecord> _transferHistory =
|
||||
<ProfileTransferRecord>[];
|
||||
final Map<String, String> _deviceProfileDefaults = <String, String>{};
|
||||
bool _isInitialized = false;
|
||||
bool _profilesEnabled = false;
|
||||
String _activeProfileId = ConfigProfile.defaultProfileId;
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get profilesEnabled => _profilesEnabled;
|
||||
String get activeProfileId => _activeProfileId;
|
||||
List<ConfigProfile> get customProfiles => List.unmodifiable(_customProfiles);
|
||||
List<ProfileTransferRecord> get transferHistory =>
|
||||
List.unmodifiable(_transferHistory);
|
||||
|
||||
List<ConfigProfile> get visibleProfiles => [
|
||||
if (_profilesEnabled) ConfigProfile.defaultProfile(),
|
||||
..._customProfiles,
|
||||
];
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_profilesEnabled = await ProfilesFeatureService.isEnabled();
|
||||
_activeProfileId =
|
||||
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
|
||||
|
||||
final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey);
|
||||
if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(deviceDefaultsJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_deviceProfileDefaults
|
||||
..clear()
|
||||
..addAll(
|
||||
decoded.map((key, value) => MapEntry(key, value?.toString() ?? ''))
|
||||
..removeWhere((key, value) => value.isEmpty),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final profilesJson = prefs.getString(_profilesKey);
|
||||
if (profilesJson != null && profilesJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(profilesJson) as List<dynamic>;
|
||||
_customProfiles
|
||||
..clear()
|
||||
..addAll(
|
||||
decoded.whereType<Map<String, dynamic>>().map(ConfigProfile.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
final historyJson = prefs.getString(_transferHistoryKey);
|
||||
if (historyJson != null && historyJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(historyJson) as List<dynamic>;
|
||||
_transferHistory
|
||||
..clear()
|
||||
..addAll(
|
||||
decoded.whereType<Map<String, dynamic>>().map(
|
||||
ProfileTransferRecord.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: _profilesEnabled,
|
||||
activeProfileId: _activeProfileId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ConfigProfile? getProfile(String id) {
|
||||
if (id == ConfigProfile.defaultProfileId) {
|
||||
return ConfigProfile.defaultProfile();
|
||||
}
|
||||
for (final profile in _customProfiles) {
|
||||
if (profile.id == id) {
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> setProfilesEnabled(bool enabled) async {
|
||||
_profilesEnabled = enabled;
|
||||
await ProfilesFeatureService.setEnabled(enabled);
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: _profilesEnabled,
|
||||
activeProfileId: _activeProfileId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setActiveProfileId(String id) async {
|
||||
await setActiveProfileIdForDevice(id);
|
||||
}
|
||||
|
||||
String profileIdForDevice(String? deviceKey) {
|
||||
if (deviceKey == null || deviceKey.isEmpty) {
|
||||
return _activeProfileId;
|
||||
}
|
||||
return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId;
|
||||
}
|
||||
|
||||
bool hasProfileForDevice(String? deviceKey) {
|
||||
if (deviceKey == null || deviceKey.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return _deviceProfileDefaults.containsKey(deviceKey);
|
||||
}
|
||||
|
||||
Future<void> setActiveProfileIdForDevice(
|
||||
String id, {
|
||||
String? deviceKey,
|
||||
}) async {
|
||||
_activeProfileId = id;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(activeProfileIdKey, id);
|
||||
if (deviceKey != null && deviceKey.isNotEmpty) {
|
||||
_deviceProfileDefaults[deviceKey] = id;
|
||||
await prefs.setString(
|
||||
_deviceProfileDefaultsKey,
|
||||
jsonEncode(_deviceProfileDefaults),
|
||||
);
|
||||
}
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: _profilesEnabled,
|
||||
activeProfileId: _activeProfileId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> upsertProfile(ConfigProfile profile) async {
|
||||
final index = _customProfiles.indexWhere((item) => item.id == profile.id);
|
||||
if (index == -1) {
|
||||
_customProfiles.add(profile);
|
||||
} else {
|
||||
_customProfiles[index] = profile;
|
||||
}
|
||||
_customProfiles.sort((a, b) => a.name.compareTo(b.name));
|
||||
await _persistProfiles();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(String id) async {
|
||||
_customProfiles.removeWhere((profile) => profile.id == id);
|
||||
await _persistProfiles();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> recordTransfer(ProfileTransferRecord record) async {
|
||||
_transferHistory.insert(0, record);
|
||||
if (_transferHistory.length > 100) {
|
||||
_transferHistory.removeRange(100, _transferHistory.length);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_transferHistoryKey,
|
||||
jsonEncode(_transferHistory.map((item) => item.toJson()).toList()),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_profilesKey,
|
||||
jsonEncode(_customProfiles.map((profile) => profile.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/services/profile_transport.dart
Normal file
10
lib/services/profile_transport.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
abstract class ProfileTransport {
|
||||
String get label;
|
||||
}
|
||||
|
||||
class FileProfileTransport implements ProfileTransport {
|
||||
const FileProfileTransport();
|
||||
|
||||
@override
|
||||
String get label => 'File';
|
||||
}
|
||||
499
lib/services/profile_workspace_coordinator.dart
Normal file
499
lib/services/profile_workspace_coordinator.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../providers/channels_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/sensors_provider.dart';
|
||||
import '../providers/voice_provider.dart';
|
||||
import '../providers/image_provider.dart' as ip;
|
||||
import 'app_config_snapshot_service.dart';
|
||||
import 'contact_storage_service.dart';
|
||||
import 'device_config_applicator.dart';
|
||||
import 'message_storage_service.dart';
|
||||
import 'profile_manager.dart';
|
||||
import 'profile_device_key_resolver.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'map_workspace_snapshot_service.dart';
|
||||
|
||||
class ProfileWorkspaceCoordinator {
|
||||
ProfileWorkspaceCoordinator({
|
||||
required this.profileManager,
|
||||
required this.connectionProvider,
|
||||
required this.contactsProvider,
|
||||
required this.messagesProvider,
|
||||
required this.sensorsProvider,
|
||||
required this.mapProvider,
|
||||
required this.drawingProvider,
|
||||
required this.channelsProvider,
|
||||
required this.voiceProvider,
|
||||
required this.imageProvider,
|
||||
required this.appProvider,
|
||||
AppConfigSnapshotService? appConfigSnapshotService,
|
||||
MapWorkspaceSnapshotService? mapWorkspaceSnapshotService,
|
||||
DeviceConfigApplicator? deviceConfigApplicator,
|
||||
MessageStorageService? messageStorageService,
|
||||
ContactStorageService? contactStorageService,
|
||||
}) : _appConfigSnapshotService =
|
||||
appConfigSnapshotService ?? AppConfigSnapshotService(),
|
||||
_mapWorkspaceSnapshotService =
|
||||
mapWorkspaceSnapshotService ?? MapWorkspaceSnapshotService(),
|
||||
_deviceConfigApplicator =
|
||||
deviceConfigApplicator ?? DeviceConfigApplicator(),
|
||||
_messageStorageService =
|
||||
messageStorageService ?? MessageStorageService(),
|
||||
_contactStorageService =
|
||||
contactStorageService ?? ContactStorageService();
|
||||
|
||||
final ProfileManager profileManager;
|
||||
final ConnectionProvider connectionProvider;
|
||||
final ContactsProvider contactsProvider;
|
||||
final MessagesProvider messagesProvider;
|
||||
final SensorsProvider sensorsProvider;
|
||||
final MapProvider mapProvider;
|
||||
final DrawingProvider drawingProvider;
|
||||
final ChannelsProvider channelsProvider;
|
||||
final VoiceProvider voiceProvider;
|
||||
final ip.ImageProvider imageProvider;
|
||||
final AppProvider appProvider;
|
||||
final AppConfigSnapshotService _appConfigSnapshotService;
|
||||
final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService;
|
||||
final DeviceConfigApplicator _deviceConfigApplicator;
|
||||
final MessageStorageService _messageStorageService;
|
||||
final ContactStorageService _contactStorageService;
|
||||
Future<void>? _deviceProfileSyncFuture;
|
||||
|
||||
Future<void> setProfilesEnabled(bool enabled) async {
|
||||
final wasEnabled = profileManager.profilesEnabled;
|
||||
if (wasEnabled && !enabled) {
|
||||
await _saveActiveCustomProfileSnapshot();
|
||||
} else {
|
||||
await _persistCurrentState();
|
||||
}
|
||||
await profileManager.setProfilesEnabled(enabled);
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: enabled,
|
||||
activeProfileId: enabled ? profileManager.activeProfileId : 'default',
|
||||
);
|
||||
if (enabled) {
|
||||
await _ensureProfileForCurrentDevice();
|
||||
if (wasEnabled) {
|
||||
await openProfile(profileManager.activeProfileId);
|
||||
} else {
|
||||
await _switchRuntimeScope(profileManager.activeProfileId);
|
||||
final profile = await resolveProfile(profileManager.activeProfileId);
|
||||
await _appConfigSnapshotService.apply(
|
||||
profile.sections.appSettings,
|
||||
appProvider,
|
||||
);
|
||||
await _mapWorkspaceSnapshotService.apply(
|
||||
profile.sections.mapWorkspace,
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _switchRuntimeScope('default');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ConfigProfile> snapshotCurrentProfile({
|
||||
required String id,
|
||||
required String name,
|
||||
String? notes,
|
||||
}) async {
|
||||
final deviceSections = _deviceConfigApplicator.capture(
|
||||
connectionProvider: connectionProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
);
|
||||
final appSettings = await _appConfigSnapshotService.capture(appProvider);
|
||||
final mapWorkspace = await _mapWorkspaceSnapshotService.capture(
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
);
|
||||
final now = DateTime.now();
|
||||
return ConfigProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
notes: notes,
|
||||
sections: ConfigProfileSections(
|
||||
deviceConfig: deviceSections.deviceConfig,
|
||||
channels: deviceSections.channels,
|
||||
appSettings: appSettings,
|
||||
mapWorkspace: mapWorkspace,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ConfigProfile> createProfileFromCurrent({
|
||||
required String name,
|
||||
String? notes,
|
||||
}) async {
|
||||
final profileId = 'profile_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final profile = await snapshotCurrentProfile(
|
||||
id: profileId,
|
||||
name: name,
|
||||
notes: notes,
|
||||
);
|
||||
await _initializeEmptyStorageNamespace(profileId);
|
||||
await profileManager.upsertProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<ConfigProfile> duplicateProfile(ConfigProfile source) async {
|
||||
final duplicate = await snapshotCurrentProfile(
|
||||
id: 'profile_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: '${source.name} Copy',
|
||||
notes: source.notes,
|
||||
);
|
||||
final sourceNamespace = source.id == ConfigProfile.defaultProfileId
|
||||
? null
|
||||
: source.id;
|
||||
await _copyStorageNamespace(sourceNamespace, duplicate.id);
|
||||
await profileManager.upsertProfile(
|
||||
duplicate.copyWith(
|
||||
sections: source.id == profileManager.activeProfileId
|
||||
? duplicate.sections
|
||||
: source.sections,
|
||||
),
|
||||
);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
Future<void> renameProfile(ConfigProfile profile, String name) async {
|
||||
await profileManager.upsertProfile(
|
||||
profile.copyWith(name: name, updatedAt: DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(ConfigProfile profile) async {
|
||||
if (profile.isDefault) return;
|
||||
if (profile.id == profileManager.activeProfileId) {
|
||||
await openProfile(ConfigProfile.defaultProfileId);
|
||||
}
|
||||
await _messageStorageService.clearMessages(namespace: profile.id);
|
||||
await _contactStorageService.clearContacts(namespace: profile.id);
|
||||
await _contactStorageService.clearContactGroups(namespace: profile.id);
|
||||
await _contactStorageService.clearPendingAdverts(namespace: profile.id);
|
||||
await profileManager.deleteProfile(profile.id);
|
||||
}
|
||||
|
||||
Future<void> openProfile(String profileId) async {
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
await _saveActiveCustomProfileSnapshot();
|
||||
await connectionProvider.disconnect();
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
profileId,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
await _switchRuntimeScope(profileId);
|
||||
|
||||
final profile = await resolveProfile(profileId);
|
||||
await _appConfigSnapshotService.apply(
|
||||
profile.sections.appSettings,
|
||||
appProvider,
|
||||
);
|
||||
await _mapWorkspaceSnapshotService.apply(
|
||||
profile.sections.mapWorkspace,
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> applyProfile(String profileId) async {
|
||||
await openProfile(profileId);
|
||||
final profile = await resolveProfile(profileId);
|
||||
await _deviceConfigApplicator.apply(
|
||||
profile,
|
||||
connectionProvider: connectionProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ConfigProfile> resolveProfile(String profileId) async {
|
||||
if (profileId == ConfigProfile.defaultProfileId) {
|
||||
return snapshotCurrentProfile(id: profileId, name: 'Default');
|
||||
}
|
||||
return profileManager.getProfile(profileId) ??
|
||||
await snapshotCurrentProfile(id: profileId, name: 'Unknown');
|
||||
}
|
||||
|
||||
Future<void> exportProfile(ConfigProfile profile) async {
|
||||
final resolved = profile.id == ConfigProfile.defaultProfileId
|
||||
? await snapshotCurrentProfile(id: profile.id, name: profile.name)
|
||||
: profile;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${tempDir.path}/${resolved.name.replaceAll(' ', '_').toLowerCase()}_${resolved.id}.meshcore_profile.json',
|
||||
);
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(resolved.toJson()),
|
||||
);
|
||||
await SharePlus.instance.share(ShareParams(files: [XFile(file.path)]));
|
||||
await profileManager.recordTransfer(
|
||||
ProfileTransferRecord(
|
||||
profileId: resolved.id,
|
||||
direction: 'export',
|
||||
timestamp: DateTime.now(),
|
||||
detail: 'Shared as file',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ConfigProfile?> importProfileFromFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final file = result.files.first;
|
||||
if (file.bytes == null) return null;
|
||||
|
||||
final decoded = jsonDecode(utf8.decode(file.bytes!));
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final imported = ConfigProfile.fromJson(decoded).copyWith(
|
||||
id: 'profile_${DateTime.now().millisecondsSinceEpoch}',
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await profileManager.upsertProfile(imported);
|
||||
await profileManager.recordTransfer(
|
||||
ProfileTransferRecord(
|
||||
profileId: imported.id,
|
||||
direction: 'import',
|
||||
timestamp: DateTime.now(),
|
||||
detail: file.name,
|
||||
),
|
||||
);
|
||||
return imported;
|
||||
}
|
||||
|
||||
Future<void> syncActiveProfileForCurrentDevice() async {
|
||||
if (!profileManager.profilesEnabled) {
|
||||
return;
|
||||
}
|
||||
final inFlightSync = _deviceProfileSyncFuture;
|
||||
if (inFlightSync != null) {
|
||||
await inFlightSync;
|
||||
return;
|
||||
}
|
||||
|
||||
final syncFuture = _syncActiveProfileForCurrentDeviceInternal();
|
||||
_deviceProfileSyncFuture = syncFuture;
|
||||
try {
|
||||
await syncFuture;
|
||||
} finally {
|
||||
if (identical(_deviceProfileSyncFuture, syncFuture)) {
|
||||
_deviceProfileSyncFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncActiveProfileForCurrentDeviceInternal() async {
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
if (deviceKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final profile = await _ensureProfileForCurrentDevice();
|
||||
final targetProfileId = profile.id;
|
||||
if (targetProfileId == profileManager.activeProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _persistCurrentState();
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
targetProfileId,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
await _switchRuntimeScope(targetProfileId);
|
||||
|
||||
await _appConfigSnapshotService.apply(
|
||||
profile.sections.appSettings,
|
||||
appProvider,
|
||||
);
|
||||
await _mapWorkspaceSnapshotService.apply(
|
||||
profile.sections.mapWorkspace,
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
|
||||
final deviceKey = _currentDeviceProfileKey;
|
||||
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
|
||||
final existingProfile = profileManager.getProfile(targetProfileId);
|
||||
if (profileManager.hasProfileForDevice(deviceKey) &&
|
||||
existingProfile != null) {
|
||||
return existingProfile;
|
||||
}
|
||||
if (profileManager.hasProfileForDevice(deviceKey) &&
|
||||
targetProfileId == ConfigProfile.defaultProfileId) {
|
||||
return ConfigProfile.defaultProfile();
|
||||
}
|
||||
if (deviceKey == null) {
|
||||
return await resolveProfile(profileManager.activeProfileId);
|
||||
}
|
||||
|
||||
final profile = await createProfileFromCurrent(
|
||||
name: _buildDeviceProfileName(),
|
||||
);
|
||||
await profileManager.setActiveProfileIdForDevice(
|
||||
profile.id,
|
||||
deviceKey: deviceKey,
|
||||
);
|
||||
return profile;
|
||||
}
|
||||
|
||||
String _buildDeviceProfileName() {
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
final name = deviceInfo.selfName?.trim();
|
||||
if (name != null && name.isNotEmpty) {
|
||||
return 'Device ${_sanitizeProfileLabel(name)}';
|
||||
}
|
||||
|
||||
final displayName = deviceInfo.displayName?.trim();
|
||||
if (displayName != null && displayName.isNotEmpty) {
|
||||
return 'Device ${_sanitizeProfileLabel(displayName)}';
|
||||
}
|
||||
|
||||
final deviceId = deviceInfo.deviceId?.trim();
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
return 'Device ${_sanitizeProfileLabel(deviceId)}';
|
||||
}
|
||||
|
||||
return 'Device Profile';
|
||||
}
|
||||
|
||||
String _sanitizeProfileLabel(String value) {
|
||||
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve(
|
||||
deviceInfo: connectionProvider.deviceInfo,
|
||||
connectionMode: connectionProvider.connectionMode,
|
||||
);
|
||||
|
||||
Future<void> _switchRuntimeScope(String profileId) async {
|
||||
final runtimeProfilesEnabled = profileManager.profilesEnabled;
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: runtimeProfilesEnabled,
|
||||
activeProfileId: profileId,
|
||||
);
|
||||
await messagesProvider.reloadFromStorage(
|
||||
namespace: ProfileStorageScope.effectiveNamespace,
|
||||
);
|
||||
await contactsProvider.reloadFromStorage(
|
||||
namespace: ProfileStorageScope.effectiveNamespace,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
await sensorsProvider.reloadProfileScopedState();
|
||||
await drawingProvider.reloadProfileScopedState();
|
||||
await mapProvider.reloadProfileScopedState();
|
||||
await voiceProvider.reloadProfileScopedState();
|
||||
await imageProvider.reloadProfileScopedState();
|
||||
await appProvider.reloadProfileScopedSettings();
|
||||
}
|
||||
|
||||
Future<void> _persistCurrentState() async {
|
||||
await messagesProvider.persistNow();
|
||||
await contactsProvider.persistNow();
|
||||
}
|
||||
|
||||
Future<void> _saveActiveCustomProfileSnapshot() async {
|
||||
if (profileManager.activeProfileId == ConfigProfile.defaultProfileId) {
|
||||
await _persistCurrentState();
|
||||
return;
|
||||
}
|
||||
final current = profileManager.getProfile(profileManager.activeProfileId);
|
||||
if (current == null) {
|
||||
await _persistCurrentState();
|
||||
return;
|
||||
}
|
||||
final snapshot = await snapshotCurrentProfile(
|
||||
id: current.id,
|
||||
name: current.name,
|
||||
notes: current.notes,
|
||||
);
|
||||
await profileManager.upsertProfile(snapshot);
|
||||
await _persistCurrentState();
|
||||
}
|
||||
|
||||
Future<void> _copyStorageNamespace(
|
||||
String? sourceNamespace,
|
||||
String targetNamespace,
|
||||
) async {
|
||||
final messages = await _messageStorageService.loadMessages(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
final contactLocations = await _messageStorageService
|
||||
.loadMessageContactLocations(namespace: sourceNamespace);
|
||||
final receptionDetails = await _messageStorageService
|
||||
.loadMessageReceptionDetails(namespace: sourceNamespace);
|
||||
final transferDetails = await _messageStorageService
|
||||
.loadMessageTransferDetails(namespace: sourceNamespace);
|
||||
final routeMetadata = await _messageStorageService.loadMessageRouteMetadata(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
final removedIds = await _messageStorageService.loadRemovedSarMarkerIds(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
await _messageStorageService.saveMessages(
|
||||
messages,
|
||||
messageContactLocations: contactLocations,
|
||||
messageReceptionDetails: receptionDetails,
|
||||
messageTransferDetails: transferDetails,
|
||||
messageRouteMetadata: routeMetadata,
|
||||
namespace: targetNamespace,
|
||||
);
|
||||
await _messageStorageService.saveRemovedSarMarkerIds(
|
||||
removedIds,
|
||||
namespace: targetNamespace,
|
||||
);
|
||||
|
||||
final contacts = await _contactStorageService.loadContacts(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
final groups = await _contactStorageService.loadContactGroups(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
final pending = await _contactStorageService.loadPendingAdverts(
|
||||
namespace: sourceNamespace,
|
||||
);
|
||||
await _contactStorageService.saveContacts(
|
||||
contacts,
|
||||
namespace: targetNamespace,
|
||||
);
|
||||
await _contactStorageService.saveContactGroups(
|
||||
groups,
|
||||
namespace: targetNamespace,
|
||||
);
|
||||
await _contactStorageService.savePendingAdverts(
|
||||
pending,
|
||||
namespace: targetNamespace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeEmptyStorageNamespace(String namespace) async {
|
||||
await _messageStorageService.clearMessages(namespace: namespace);
|
||||
await _contactStorageService.clearContacts(namespace: namespace);
|
||||
await _contactStorageService.clearContactGroups(namespace: namespace);
|
||||
await _contactStorageService.clearPendingAdverts(namespace: namespace);
|
||||
}
|
||||
}
|
||||
54
lib/services/profiles_feature_service.dart
Normal file
54
lib/services/profiles_feature_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ProfilesFeatureService {
|
||||
static const String enabledKey = 'profiles_enabled';
|
||||
|
||||
static Future<bool> isEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(enabledKey) ?? true;
|
||||
}
|
||||
|
||||
static Future<void> setEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(enabledKey, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileStorageScope {
|
||||
static bool _profilesEnabled = true;
|
||||
static String _activeProfileId = 'default';
|
||||
|
||||
static Future<void> bootstrap({
|
||||
required bool profilesEnabled,
|
||||
required String activeProfileId,
|
||||
}) async {
|
||||
_profilesEnabled = profilesEnabled;
|
||||
_activeProfileId = activeProfileId;
|
||||
}
|
||||
|
||||
static void setScope({
|
||||
required bool profilesEnabled,
|
||||
required String activeProfileId,
|
||||
}) {
|
||||
_profilesEnabled = profilesEnabled;
|
||||
_activeProfileId = activeProfileId;
|
||||
}
|
||||
|
||||
static bool get profilesEnabled => _profilesEnabled;
|
||||
static String get activeProfileId => _activeProfileId;
|
||||
|
||||
static String? get effectiveNamespace {
|
||||
if (!_profilesEnabled || _activeProfileId == 'default') {
|
||||
return null;
|
||||
}
|
||||
return _activeProfileId;
|
||||
}
|
||||
|
||||
static String scopedKey(String baseKey, {String? namespace}) {
|
||||
final scope = namespace ?? effectiveNamespace;
|
||||
if (scope == null || scope.isEmpty) {
|
||||
return baseKey;
|
||||
}
|
||||
return 'profile.$scope.$baseKey';
|
||||
}
|
||||
}
|
||||
102
lib/services/recent_tcp_connections_service.dart
Normal file
102
lib/services/recent_tcp_connections_service.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class RecentTcpConnection {
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
const RecentTcpConnection({
|
||||
required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
Map<String, Object> toJson() => <String, Object>{
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory RecentTcpConnection.fromJson(Map<String, dynamic> json) {
|
||||
return RecentTcpConnection(
|
||||
name: (json['name'] as String?)?.trim().isNotEmpty == true
|
||||
? (json['name'] as String).trim()
|
||||
: ((json['host'] as String?) ?? '').trim(),
|
||||
host: ((json['host'] as String?) ?? '').trim(),
|
||||
port: (json['port'] as num?)?.toInt() ?? 0,
|
||||
lastUsedAt:
|
||||
DateTime.tryParse((json['lastUsedAt'] as String?) ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecentTcpConnectionsService {
|
||||
static const String _prefsKey = 'recent_tcp_connections_v1';
|
||||
static const int _maxEntries = 5;
|
||||
|
||||
static Future<List<RecentTcpConnection>> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedEntries = prefs.getStringList(_prefsKey) ?? const <String>[];
|
||||
|
||||
final connections = <RecentTcpConnection>[];
|
||||
for (final entry in storedEntries) {
|
||||
try {
|
||||
final decoded = jsonDecode(entry);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
final connection = RecentTcpConnection.fromJson(decoded);
|
||||
if (connection.host.isEmpty || connection.port <= 0) {
|
||||
continue;
|
||||
}
|
||||
connections.add(connection);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
connections.sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
return connections.take(_maxEntries).toList(growable: false);
|
||||
}
|
||||
|
||||
static Future<List<RecentTcpConnection>> remember({
|
||||
required String name,
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final trimmedHost = host.trim();
|
||||
if (trimmedHost.isEmpty || port <= 0) {
|
||||
return load();
|
||||
}
|
||||
|
||||
final normalizedName = name.trim().isNotEmpty ? name.trim() : trimmedHost;
|
||||
final existing = await load();
|
||||
final updated = <RecentTcpConnection>[
|
||||
RecentTcpConnection(
|
||||
name: normalizedName,
|
||||
host: trimmedHost,
|
||||
port: port,
|
||||
lastUsedAt: DateTime.now(),
|
||||
),
|
||||
...existing.where(
|
||||
(connection) =>
|
||||
connection.host != trimmedHost || connection.port != port,
|
||||
),
|
||||
].take(_maxEntries).toList(growable: false);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_prefsKey,
|
||||
updated
|
||||
.map((connection) => jsonEncode(connection.toJson()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
86
lib/services/region_scope_preferences.dart
Normal file
86
lib/services/region_scope_preferences.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Stores per-channel region scope settings.
|
||||
///
|
||||
/// Each channel can optionally have a region scope assigned. When set, the app
|
||||
/// will set the flood scope on the device before sending channel messages so
|
||||
/// that repeaters outside the region won't forward them.
|
||||
class RegionScopePreferences {
|
||||
static const String _namePrefix = 'region_scope_name_';
|
||||
static const String _keyPrefix = 'region_scope_key_';
|
||||
|
||||
/// Get the saved scope for a channel.
|
||||
/// Returns null if no scope is set.
|
||||
static Future<({String name, Uint8List key})?> getScope(
|
||||
int channelIdx,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final name = prefs.getString(
|
||||
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
|
||||
);
|
||||
final keyBase64 = prefs.getString(
|
||||
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
|
||||
);
|
||||
if (name == null || keyBase64 == null) return null;
|
||||
try {
|
||||
return (name: name, key: Uint8List.fromList(base64.decode(keyBase64)));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a region scope for a channel.
|
||||
static Future<void> setScope(
|
||||
int channelIdx,
|
||||
String name,
|
||||
Uint8List key,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
|
||||
name,
|
||||
);
|
||||
await prefs.setString(
|
||||
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
|
||||
base64.encode(key),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear the region scope for a channel.
|
||||
static Future<void> clearScope(int channelIdx) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(
|
||||
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
|
||||
);
|
||||
await prefs.remove(
|
||||
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all region scopes (all channels).
|
||||
static Future<void> clearAllScopes() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keys = prefs.getKeys();
|
||||
for (final key in keys) {
|
||||
if (key.contains(_namePrefix) || key.contains(_keyPrefix)) {
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the 16-byte transport key for a region name.
|
||||
///
|
||||
/// Matches firmware `TransportKeyStore::getAutoKeyFor`:
|
||||
/// `SHA256("#regionname")` → first 16 bytes.
|
||||
/// The name must include the leading `#` (auto-prepended if missing).
|
||||
static Uint8List deriveRegionKey(String regionName) {
|
||||
final normalized =
|
||||
regionName.startsWith('#') ? regionName : '#$regionName';
|
||||
final digest = sha256.convert(utf8.encode(normalized));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
}
|
||||
48
lib/services/relay_candidate_sorter.dart
Normal file
48
lib/services/relay_candidate_sorter.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class RelayCandidateSorter {
|
||||
const RelayCandidateSorter();
|
||||
|
||||
List<Contact> sortByDistanceFromSelf(
|
||||
List<Contact> contacts, {
|
||||
required LatLng? selfPoint,
|
||||
}) {
|
||||
final sorted = List<Contact>.from(contacts);
|
||||
sorted.sort((a, b) {
|
||||
if (selfPoint != null) {
|
||||
final distanceCompare = _distanceFrom(
|
||||
selfPoint,
|
||||
a,
|
||||
).compareTo(_distanceFrom(selfPoint, b));
|
||||
if (distanceCompare != 0) {
|
||||
return distanceCompare;
|
||||
}
|
||||
}
|
||||
|
||||
final nameCompare = a.displayName.compareTo(b.displayName);
|
||||
if (nameCompare != 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
double _distanceFrom(LatLng selfPoint, Contact contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) {
|
||||
return double.infinity;
|
||||
}
|
||||
|
||||
return Geolocator.distanceBetween(
|
||||
selfPoint.latitude,
|
||||
selfPoint.longitude,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/services/route_hash_preferences.dart
Normal file
34
lib/services/route_hash_preferences.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
class RouteHashPreferences {
|
||||
static const String _hashSizeKey = 'route_hash_size';
|
||||
static const int defaultHashSize = 1;
|
||||
|
||||
static Future<int> getHashSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ??
|
||||
defaultHashSize;
|
||||
return _normalize(value);
|
||||
}
|
||||
|
||||
static Future<void> setHashSize(int value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(
|
||||
ProfileStorageScope.scopedKey(_hashSizeKey),
|
||||
_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.
|
||||
static const List<int> supportedSizes = [1, 2, 3];
|
||||
|
||||
static int _normalize(int value) {
|
||||
if (supportedSizes.contains(value)) return value;
|
||||
return defaultHashSize;
|
||||
}
|
||||
}
|
||||
244
lib/services/sar_template_service.dart
Normal file
244
lib/services/sar_template_service.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/sar_template.dart';
|
||||
import '../utils/sar_message_parser.dart';
|
||||
|
||||
/// SAR Template Service - Manages SAR templates with persistence
|
||||
class SarTemplateService extends ChangeNotifier {
|
||||
static final SarTemplateService _instance = SarTemplateService._internal();
|
||||
factory SarTemplateService() => _instance;
|
||||
SarTemplateService._internal();
|
||||
|
||||
static const String _storageKey = 'sar_templates';
|
||||
List<SarTemplate> _templates = [];
|
||||
bool _initialized = false;
|
||||
|
||||
/// Get all templates
|
||||
List<SarTemplate> get templates => List.unmodifiable(_templates);
|
||||
|
||||
/// Get default templates
|
||||
List<SarTemplate> get defaultTemplates =>
|
||||
_templates.where((t) => t.isDefault).toList();
|
||||
|
||||
/// Get custom templates
|
||||
List<SarTemplate> get customTemplates =>
|
||||
_templates.where((t) => !t.isDefault).toList();
|
||||
|
||||
/// Check if initialized
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Initialize service and load templates
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_storageKey);
|
||||
|
||||
if (jsonString != null && jsonString.isNotEmpty) {
|
||||
// Load saved templates
|
||||
final List<dynamic> jsonList = json.decode(jsonString);
|
||||
_templates = jsonList.map((json) => SarTemplate.fromJson(json)).toList();
|
||||
|
||||
// Ensure defaults exist (in case user deleted them or version upgrade)
|
||||
_ensureDefaultTemplates();
|
||||
} else {
|
||||
// First time - initialize with defaults
|
||||
_templates = SarTemplate.defaults;
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
notifyListeners();
|
||||
debugPrint('SarTemplateService initialized with ${_templates.length} templates');
|
||||
} catch (e) {
|
||||
debugPrint('Error initializing SAR templates: $e');
|
||||
// Fallback to defaults on error
|
||||
_templates = SarTemplate.defaults;
|
||||
_initialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure default templates exist
|
||||
void _ensureDefaultTemplates() {
|
||||
final defaults = SarTemplate.defaults;
|
||||
final existingDefaultIds = _templates.where((t) => t.isDefault).map((t) => t.id).toSet();
|
||||
|
||||
// Add missing defaults
|
||||
for (final defaultTemplate in defaults) {
|
||||
if (!existingDefaultIds.contains(defaultTemplate.id)) {
|
||||
_templates.insert(0, defaultTemplate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save templates to storage
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _templates.map((t) => t.toJson()).toList();
|
||||
final jsonString = json.encode(jsonList);
|
||||
await prefs.setString(_storageKey, jsonString);
|
||||
debugPrint('Saved ${_templates.length} SAR templates to storage');
|
||||
} catch (e) {
|
||||
debugPrint('Error saving SAR templates: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add new template
|
||||
Future<void> addTemplate(SarTemplate template) async {
|
||||
_templates.add(template);
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
debugPrint('Added SAR template: ${template.name}');
|
||||
}
|
||||
|
||||
/// Update existing template
|
||||
Future<void> updateTemplate(String id, SarTemplate updatedTemplate) async {
|
||||
final index = _templates.indexWhere((t) => t.id == id);
|
||||
if (index != -1) {
|
||||
_templates[index] = updatedTemplate;
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
debugPrint('Updated SAR template: ${updatedTemplate.name}');
|
||||
} else {
|
||||
throw Exception('Template with id $id not found');
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete template
|
||||
Future<void> deleteTemplate(String id) async {
|
||||
final template = _templates.firstWhere((t) => t.id == id);
|
||||
_templates.removeWhere((t) => t.id == id);
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
debugPrint('Deleted SAR template: ${template.name}');
|
||||
}
|
||||
|
||||
/// Get template by ID
|
||||
SarTemplate? getTemplateById(String id) {
|
||||
try {
|
||||
return _templates.firstWhere((t) => t.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import templates from clipboard
|
||||
/// Expects SAR message format (one per line):
|
||||
/// S:🧑:0,0:Person found
|
||||
/// S:🔥:0,0:Active fire
|
||||
Future<int> importFromClipboard() async {
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData == null || clipboardData.text == null || clipboardData.text!.trim().isEmpty) {
|
||||
throw Exception('Clipboard is empty');
|
||||
}
|
||||
|
||||
return importFromText(clipboardData.text!);
|
||||
} catch (e) {
|
||||
debugPrint('Error importing from clipboard: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import templates from text (SAR message format)
|
||||
Future<int> importFromText(String text) async {
|
||||
try {
|
||||
final lines = text.split('\n').where((line) => line.trim().isNotEmpty).toList();
|
||||
int importedCount = 0;
|
||||
final List<String> errors = [];
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (!trimmed.startsWith('S:')) {
|
||||
errors.add('Invalid format: $trimmed');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate with parser
|
||||
if (!SarMessageParser.isValidFormat(trimmed)) {
|
||||
final error = SarMessageParser.getFormatError(trimmed);
|
||||
errors.add(error ?? 'Invalid SAR message format');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final template = SarTemplate.fromSarMessage(trimmed);
|
||||
|
||||
// Check for duplicates (same emoji + name)
|
||||
final isDuplicate = _templates.any((t) =>
|
||||
t.emoji == template.emoji && t.name == template.name
|
||||
);
|
||||
|
||||
if (!isDuplicate) {
|
||||
_templates.add(template);
|
||||
importedCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.add('Error parsing line: $trimmed - $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (importedCount > 0) {
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
debugPrint('Import errors: ${errors.join(', ')}');
|
||||
}
|
||||
|
||||
debugPrint('Imported $importedCount SAR templates');
|
||||
return importedCount;
|
||||
} catch (e) {
|
||||
debugPrint('Error importing templates: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all templates to clipboard (SAR message format)
|
||||
Future<void> exportToClipboard() async {
|
||||
try {
|
||||
final sarMessages = _templates.map((t) => t.toSarMessage()).join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: sarMessages));
|
||||
debugPrint('Exported ${_templates.length} templates to clipboard');
|
||||
} catch (e) {
|
||||
debugPrint('Error exporting to clipboard: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Export templates to text (SAR message format)
|
||||
String exportToText() {
|
||||
return _templates.map((t) => t.toSarMessage()).join('\n');
|
||||
}
|
||||
|
||||
/// Reset to default templates
|
||||
Future<void> resetToDefaults() async {
|
||||
_templates = SarTemplate.defaults;
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
debugPrint('Reset to default SAR templates');
|
||||
}
|
||||
|
||||
/// Clear all templates (including defaults)
|
||||
Future<void> clearAll() async {
|
||||
_templates.clear();
|
||||
await _saveToStorage();
|
||||
notifyListeners();
|
||||
debugPrint('Cleared all SAR templates');
|
||||
}
|
||||
|
||||
/// Get count of templates
|
||||
int get templateCount => _templates.length;
|
||||
|
||||
/// Check if template exists
|
||||
bool hasTemplate(String id) {
|
||||
return _templates.any((t) => t.id == id);
|
||||
}
|
||||
}
|
||||
50
lib/services/serial/serial_transport.dart
Normal file
50
lib/services/serial/serial_transport.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
import 'serial_transport_stub.dart'
|
||||
if (dart.library.io) 'serial_transport_io.dart'
|
||||
if (dart.library.js_interop) 'serial_transport_web.dart';
|
||||
|
||||
const int kSerialBaudRate = 115200;
|
||||
|
||||
class SerialDeviceInfo {
|
||||
final String id;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Object handle;
|
||||
|
||||
const SerialDeviceInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.handle,
|
||||
});
|
||||
}
|
||||
|
||||
class SerialConnection {
|
||||
final MeshCoreSerialService service;
|
||||
final String deviceId;
|
||||
final String deviceName;
|
||||
final Future<void> Function() disconnect;
|
||||
|
||||
const SerialConnection({
|
||||
required this.service,
|
||||
required this.deviceId,
|
||||
required this.deviceName,
|
||||
required this.disconnect,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class SerialTransport {
|
||||
bool get isSupported;
|
||||
bool get canRequestDevice;
|
||||
String get actionLabel;
|
||||
String get emptyStateTitle;
|
||||
String get unsupportedMessage;
|
||||
|
||||
Future<List<SerialDeviceInfo>> listDevices();
|
||||
Future<SerialDeviceInfo?> requestDevice();
|
||||
Future<SerialConnection> connect(SerialDeviceInfo device);
|
||||
void dispose();
|
||||
}
|
||||
|
||||
SerialTransport createSerialTransport() => createSerialTransportImpl();
|
||||
283
lib/services/serial/serial_transport_io.dart
Normal file
283
lib/services/serial/serial_transport_io.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_libserialport/flutter_libserialport.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
|
||||
import 'serial_transport.dart';
|
||||
|
||||
class _IoSerialTransport implements SerialTransport {
|
||||
@override
|
||||
bool get isSupported =>
|
||||
Platform.isAndroid || Platform.isMacOS || Platform.isWindows;
|
||||
|
||||
@override
|
||||
bool get canRequestDevice => false;
|
||||
|
||||
@override
|
||||
String get actionLabel =>
|
||||
Platform.isAndroid ? 'Scan USB devices' : 'Scan serial ports';
|
||||
|
||||
@override
|
||||
String get emptyStateTitle => Platform.isAndroid
|
||||
? 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.'
|
||||
: 'No serial ports found.\nConnect a MeshCore device over USB serial.';
|
||||
|
||||
@override
|
||||
String get unsupportedMessage =>
|
||||
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
|
||||
|
||||
@override
|
||||
Future<List<SerialDeviceInfo>> listDevices() async {
|
||||
if (Platform.isAndroid) {
|
||||
final devices = await UsbSerial.listDevices();
|
||||
return devices.map(_androidDeviceInfo).toList(growable: false);
|
||||
}
|
||||
if (Platform.isMacOS || Platform.isWindows) {
|
||||
return _desktopSerialDevices();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SerialDeviceInfo?> requestDevice() async => null;
|
||||
|
||||
@override
|
||||
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||
if (Platform.isAndroid) {
|
||||
return _connectAndroid(device);
|
||||
}
|
||||
if (Platform.isMacOS || Platform.isWindows) {
|
||||
return _connectDesktop(device);
|
||||
}
|
||||
throw UnsupportedError(unsupportedMessage);
|
||||
}
|
||||
|
||||
SerialDeviceInfo _androidDeviceInfo(UsbDevice device) {
|
||||
final title = _firstNonEmpty([
|
||||
device.productName,
|
||||
device.manufacturerName,
|
||||
'USB Serial Device',
|
||||
]);
|
||||
final subtitleParts = <String>[
|
||||
if ((device.manufacturerName ?? '').trim().isNotEmpty)
|
||||
device.manufacturerName!.trim(),
|
||||
if (device.vid != null && device.pid != null)
|
||||
'VID:${_hex(device.vid!)} PID:${_hex(device.pid!)}',
|
||||
];
|
||||
|
||||
return SerialDeviceInfo(
|
||||
id: '${device.deviceId}:${device.vid ?? 'na'}:${device.pid ?? 'na'}:${device.productName ?? 'usb'}',
|
||||
title: title,
|
||||
subtitle: subtitleParts.isEmpty
|
||||
? 'Ready over OTG serial'
|
||||
: subtitleParts.join(' • '),
|
||||
handle: device,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SerialDeviceInfo>> _desktopSerialDevices() async {
|
||||
final ports = <SerialDeviceInfo>[];
|
||||
for (final address in SerialPort.availablePorts) {
|
||||
final port = SerialPort(address);
|
||||
try {
|
||||
final title = _firstNonEmpty([
|
||||
port.productName,
|
||||
port.description,
|
||||
address,
|
||||
]);
|
||||
final subtitleParts = <String>[
|
||||
address,
|
||||
if ((port.manufacturer ?? '').trim().isNotEmpty)
|
||||
port.manufacturer!.trim(),
|
||||
if (port.vendorId != null && port.productId != null)
|
||||
'VID:${_hex(port.vendorId!)} PID:${_hex(port.productId!)}',
|
||||
];
|
||||
ports.add(
|
||||
SerialDeviceInfo(
|
||||
id: address,
|
||||
title: title,
|
||||
subtitle: subtitleParts.join(' • '),
|
||||
handle: address,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
port.dispose();
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
Future<SerialConnection> _connectAndroid(SerialDeviceInfo device) async {
|
||||
final usbDevice = device.handle as UsbDevice;
|
||||
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||
StreamSubscription<Uint8List>? inputSubscription;
|
||||
UsbPort? port;
|
||||
|
||||
try {
|
||||
port = await usbDevice.create();
|
||||
if (port == null) {
|
||||
throw Exception('Failed to create USB port');
|
||||
}
|
||||
final opened = await port.open();
|
||||
if (!opened) {
|
||||
throw Exception('Failed to open USB port');
|
||||
}
|
||||
|
||||
await port.setDTR(true);
|
||||
await port.setRTS(true);
|
||||
await port.setPortParameters(
|
||||
kSerialBaudRate,
|
||||
UsbPort.DATABITS_8,
|
||||
UsbPort.STOPBITS_1,
|
||||
UsbPort.PARITY_NONE,
|
||||
);
|
||||
|
||||
service.writeRaw = (data) async {
|
||||
await port!.write(data);
|
||||
};
|
||||
|
||||
inputSubscription = port.inputStream?.listen(
|
||||
(data) => service.feedRawBytes(data),
|
||||
onError: (error) {
|
||||
debugPrint('❌ [Serial/Android] Read error: $error');
|
||||
service.markDisconnected();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('⚠️ [Serial/Android] Port closed');
|
||||
service.markDisconnected();
|
||||
},
|
||||
);
|
||||
|
||||
final connected = await service.markConnected();
|
||||
if (!connected) {
|
||||
throw Exception('Serial session initialization failed');
|
||||
}
|
||||
|
||||
return SerialConnection(
|
||||
service: service,
|
||||
deviceId:
|
||||
'${usbDevice.deviceId}:${usbDevice.vid ?? 'na'}:${usbDevice.pid ?? 'na'}',
|
||||
deviceName: device.title,
|
||||
disconnect: () async {
|
||||
await inputSubscription?.cancel();
|
||||
inputSubscription = null;
|
||||
service.writeRaw = null;
|
||||
await port?.close();
|
||||
port = null;
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
await inputSubscription?.cancel();
|
||||
service.writeRaw = null;
|
||||
await port?.close();
|
||||
service.dispose();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SerialConnection> _connectDesktop(SerialDeviceInfo device) async {
|
||||
final portName = device.handle as String;
|
||||
final port = SerialPort(portName);
|
||||
final config = SerialPortConfig();
|
||||
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||
SerialPortReader? reader;
|
||||
StreamSubscription<Uint8List>? inputSubscription;
|
||||
|
||||
try {
|
||||
if (!port.openReadWrite()) {
|
||||
throw Exception(
|
||||
SerialPort.lastError?.message ?? 'Failed to open serial port',
|
||||
);
|
||||
}
|
||||
|
||||
config.baudRate = kSerialBaudRate;
|
||||
config.bits = 8;
|
||||
config.stopBits = 1;
|
||||
config.parity = SerialPortParity.none;
|
||||
config.setFlowControl(SerialPortFlowControl.none);
|
||||
config.dtr = SerialPortDtr.on;
|
||||
config.rts = SerialPortRts.on;
|
||||
port.config = config;
|
||||
|
||||
service.writeRaw = (data) async {
|
||||
var offset = 0;
|
||||
while (offset < data.length) {
|
||||
final written = port.write(data.sublist(offset));
|
||||
if (written <= 0) {
|
||||
throw Exception(
|
||||
SerialPort.lastError?.message ?? 'Failed to write to serial port',
|
||||
);
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
};
|
||||
|
||||
reader = SerialPortReader(port);
|
||||
inputSubscription = reader.stream.listen(
|
||||
(data) => service.feedRawBytes(data),
|
||||
onError: (error) {
|
||||
debugPrint('❌ [Serial/Desktop] Read error: $error');
|
||||
service.markDisconnected();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('⚠️ [Serial/Desktop] Reader closed');
|
||||
service.markDisconnected();
|
||||
},
|
||||
);
|
||||
|
||||
final connected = await service.markConnected();
|
||||
if (!connected) {
|
||||
throw Exception('Serial session initialization failed');
|
||||
}
|
||||
|
||||
return SerialConnection(
|
||||
service: service,
|
||||
deviceId: portName,
|
||||
deviceName: device.title,
|
||||
disconnect: () async {
|
||||
await inputSubscription?.cancel();
|
||||
inputSubscription = null;
|
||||
reader?.close();
|
||||
reader = null;
|
||||
service.writeRaw = null;
|
||||
if (port.isOpen) {
|
||||
port.close();
|
||||
}
|
||||
port.dispose();
|
||||
config.dispose();
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
await inputSubscription?.cancel();
|
||||
reader?.close();
|
||||
service.writeRaw = null;
|
||||
if (port.isOpen) {
|
||||
port.close();
|
||||
}
|
||||
port.dispose();
|
||||
config.dispose();
|
||||
service.dispose();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
String _firstNonEmpty(List<String?> values) {
|
||||
for (final value in values) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return 'Serial Device';
|
||||
}
|
||||
|
||||
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
|
||||
|
||||
SerialTransport createSerialTransportImpl() => _IoSerialTransport();
|
||||
35
lib/services/serial/serial_transport_stub.dart
Normal file
35
lib/services/serial/serial_transport_stub.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'serial_transport.dart';
|
||||
|
||||
class _UnsupportedSerialTransport implements SerialTransport {
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
bool get canRequestDevice => false;
|
||||
|
||||
@override
|
||||
String get actionLabel => 'Serial unsupported';
|
||||
|
||||
@override
|
||||
String get emptyStateTitle => 'Serial is unavailable on this platform.';
|
||||
|
||||
@override
|
||||
String get unsupportedMessage =>
|
||||
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
|
||||
|
||||
@override
|
||||
Future<List<SerialDeviceInfo>> listDevices() async => const [];
|
||||
|
||||
@override
|
||||
Future<SerialDeviceInfo?> requestDevice() async => null;
|
||||
|
||||
@override
|
||||
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||
throw UnsupportedError(unsupportedMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport();
|
||||
209
lib/services/serial/serial_transport_web.dart
Normal file
209
lib/services/serial/serial_transport_web.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:webserial/webserial.dart';
|
||||
|
||||
import 'serial_transport.dart';
|
||||
|
||||
class _WebSerialTransport implements SerialTransport {
|
||||
@override
|
||||
bool get isSupported =>
|
||||
web.window.navigator.hasProperty('serial'.toJS).toDart;
|
||||
|
||||
@override
|
||||
bool get canRequestDevice => true;
|
||||
|
||||
@override
|
||||
String get actionLabel => 'Choose serial device';
|
||||
|
||||
@override
|
||||
String get emptyStateTitle =>
|
||||
'No serial devices granted yet.\nChoose a device and approve access in your browser.';
|
||||
|
||||
@override
|
||||
String get unsupportedMessage =>
|
||||
'Web Serial requires a compatible browser such as Chrome or Edge and a secure context (HTTPS or localhost).';
|
||||
|
||||
@override
|
||||
Future<List<SerialDeviceInfo>> listDevices() async {
|
||||
if (!isSupported) {
|
||||
return const [];
|
||||
}
|
||||
final ports = await serial.getPorts().toDart;
|
||||
return ports
|
||||
.toDart
|
||||
.whereType<JSSerialPort>()
|
||||
.map(_deviceInfoForPort)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SerialDeviceInfo?> requestDevice() async {
|
||||
if (!isSupported) {
|
||||
return null;
|
||||
}
|
||||
final port = await requestWebSerialPort(null);
|
||||
if (port == null) {
|
||||
return null;
|
||||
}
|
||||
return _deviceInfoForPort(port);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SerialConnection> connect(SerialDeviceInfo device) async {
|
||||
final port = device.handle as JSSerialPort;
|
||||
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
|
||||
web.ReadableStreamDefaultReader? reader;
|
||||
bool keepReading = false;
|
||||
|
||||
try {
|
||||
await port
|
||||
.open(
|
||||
JSSerialOptions(
|
||||
baudRate: kSerialBaudRate,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
bufferSize: 1024,
|
||||
flowControl: 'none',
|
||||
),
|
||||
)
|
||||
.toDart;
|
||||
|
||||
await port
|
||||
.setSignals(
|
||||
JSSerialOutputSignals(
|
||||
dataTerminalReady: true,
|
||||
requestToSend: true,
|
||||
),
|
||||
)
|
||||
.toDart;
|
||||
|
||||
service.writeRaw = (data) async {
|
||||
final writable = port.writable;
|
||||
if (writable == null) {
|
||||
throw Exception('Serial port is not writable');
|
||||
}
|
||||
final writer =
|
||||
writable.getWriter() as web.WritableStreamDefaultWriter?;
|
||||
if (writer == null) {
|
||||
throw Exception('Failed to acquire serial writer');
|
||||
}
|
||||
try {
|
||||
await writer.write(data.toJS).toDart;
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
final readable = port.readable;
|
||||
if (readable == null) {
|
||||
throw Exception('Serial port is not readable');
|
||||
}
|
||||
|
||||
reader = readable.getReader() as web.ReadableStreamDefaultReader?;
|
||||
if (reader == null) {
|
||||
throw Exception('Failed to acquire serial reader');
|
||||
}
|
||||
|
||||
keepReading = true;
|
||||
unawaited(_readLoop(reader, service, () => keepReading));
|
||||
|
||||
final connected = await service.markConnected();
|
||||
if (!connected) {
|
||||
throw Exception('Serial session initialization failed');
|
||||
}
|
||||
|
||||
return SerialConnection(
|
||||
service: service,
|
||||
deviceId: device.id,
|
||||
deviceName: device.title,
|
||||
disconnect: () async {
|
||||
keepReading = false;
|
||||
try {
|
||||
await reader?.cancel().toDart;
|
||||
} catch (_) {}
|
||||
try {
|
||||
reader?.releaseLock();
|
||||
} catch (_) {}
|
||||
reader = null;
|
||||
service.writeRaw = null;
|
||||
try {
|
||||
await port.close().toDart;
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
keepReading = false;
|
||||
try {
|
||||
await reader?.cancel().toDart;
|
||||
} catch (_) {}
|
||||
try {
|
||||
reader?.releaseLock();
|
||||
} catch (_) {}
|
||||
service.writeRaw = null;
|
||||
try {
|
||||
await port.close().toDart;
|
||||
} catch (_) {}
|
||||
service.dispose();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _readLoop(
|
||||
web.ReadableStreamDefaultReader reader,
|
||||
MeshCoreSerialService service,
|
||||
bool Function() shouldContinue,
|
||||
) async {
|
||||
while (shouldContinue()) {
|
||||
try {
|
||||
final result = await reader.read().toDart;
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
final value = result.value;
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
service.feedRawBytes((value as JSUint8Array).toDart);
|
||||
} catch (_) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
service.markDisconnected();
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
SerialDeviceInfo _deviceInfoForPort(JSSerialPort port) {
|
||||
final info = port.getInfo();
|
||||
final vendorId = info.usbVendorId;
|
||||
final productId = info.usbProductId;
|
||||
final title = (vendorId != 0 || productId != 0)
|
||||
? 'USB ${_hex(vendorId)}:${_hex(productId)}'
|
||||
: 'Granted serial device';
|
||||
final subtitle = [
|
||||
if (vendorId != 0 || productId != 0)
|
||||
'VID:${_hex(vendorId)} PID:${_hex(productId)}',
|
||||
'Web Serial',
|
||||
].join(' • ');
|
||||
|
||||
return SerialDeviceInfo(
|
||||
id: '$title::$subtitle',
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
handle: port,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
|
||||
|
||||
SerialTransport createSerialTransportImpl() => _WebSerialTransport();
|
||||
100
lib/services/serial/usb_serial_transport.dart
Normal file
100
lib/services/serial/usb_serial_transport.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
/// Android USB OTG transport for MeshCore serial connection.
|
||||
///
|
||||
/// Wraps the `usb_serial` package to provide raw byte I/O to
|
||||
/// [MeshCoreSerialService].
|
||||
class UsbSerialTransport {
|
||||
static const int _baudRate = 115200;
|
||||
|
||||
UsbPort? _port;
|
||||
StreamSubscription? _inputSubscription;
|
||||
final MeshCoreSerialService _service;
|
||||
|
||||
UsbSerialTransport(this._service);
|
||||
|
||||
MeshCoreSerialService get service => _service;
|
||||
|
||||
bool get isConnected => _port != null;
|
||||
|
||||
/// List available USB serial devices.
|
||||
static Future<List<UsbDevice>> listDevices() async {
|
||||
return UsbSerial.listDevices();
|
||||
}
|
||||
|
||||
/// Connect to a USB serial device.
|
||||
Future<bool> connect(UsbDevice device) async {
|
||||
try {
|
||||
_port = await device.create();
|
||||
if (_port == null) {
|
||||
debugPrint('❌ [USB] Failed to create port for ${device.productName}');
|
||||
return false;
|
||||
}
|
||||
|
||||
final opened = await _port!.open();
|
||||
if (!opened) {
|
||||
debugPrint('❌ [USB] Failed to open port');
|
||||
_port = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
await _port!.setDTR(true);
|
||||
await _port!.setRTS(true);
|
||||
await _port!.setPortParameters(
|
||||
_baudRate,
|
||||
UsbPort.DATABITS_8,
|
||||
UsbPort.STOPBITS_1,
|
||||
UsbPort.PARITY_NONE,
|
||||
);
|
||||
|
||||
// Wire write callback
|
||||
_service.writeRaw = _write;
|
||||
|
||||
// Listen for incoming data
|
||||
_inputSubscription = _port!.inputStream?.listen(
|
||||
(data) => _service.feedRawBytes(data),
|
||||
onError: (error) {
|
||||
debugPrint('❌ [USB] Read error: $error');
|
||||
disconnect();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('⚠️ [USB] Port closed');
|
||||
disconnect();
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [USB] Connected to ${device.productName} at $_baudRate baud',
|
||||
);
|
||||
|
||||
// Initialize MeshCore session
|
||||
return _service.markConnected();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [USB] Connection failed: $e');
|
||||
await disconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from the USB device.
|
||||
Future<void> disconnect() async {
|
||||
_service.markDisconnected();
|
||||
_service.writeRaw = null;
|
||||
await _inputSubscription?.cancel();
|
||||
_inputSubscription = null;
|
||||
await _port?.close();
|
||||
_port = null;
|
||||
}
|
||||
|
||||
Future<void> _write(Uint8List data) async {
|
||||
if (_port == null) throw Exception('USB not connected');
|
||||
await _port!.write(data);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
20
lib/services/serial/web_serial_transport.dart
Normal file
20
lib/services/serial/web_serial_transport.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
// Web Serial API is only available on web platform.
|
||||
// This file provides the interface; the actual JS interop is conditional.
|
||||
|
||||
/// Web Serial transport stub for non-web platforms.
|
||||
/// On web, use [WebSerialTransportImpl] from web_serial_transport_web.dart.
|
||||
class WebSerialTransport {
|
||||
final MeshCoreSerialService service;
|
||||
|
||||
WebSerialTransport(this.service);
|
||||
|
||||
bool get isConnected => false;
|
||||
bool get isSupported => false;
|
||||
|
||||
Future<bool> requestAndConnect() async => false;
|
||||
Future<void> disconnect() async {}
|
||||
void dispose() {}
|
||||
}
|
||||
342
lib/services/tile_download_service.dart
Normal file
342
lib/services/tile_download_service.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'offline_tile_cache_service.dart';
|
||||
import 'tile_math_service.dart';
|
||||
|
||||
/// Events emitted during tile download.
|
||||
sealed class TileDownloadEvent {}
|
||||
|
||||
class TileDownloadStarted extends TileDownloadEvent {
|
||||
final int totalTiles;
|
||||
TileDownloadStarted(this.totalTiles);
|
||||
}
|
||||
|
||||
class TileDownloaded extends TileDownloadEvent {
|
||||
final double north, south, east, west;
|
||||
TileDownloaded({
|
||||
required this.north,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.west,
|
||||
});
|
||||
}
|
||||
|
||||
class TileSkipped extends TileDownloadEvent {
|
||||
final double north, south, east, west;
|
||||
TileSkipped({
|
||||
required this.north,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.west,
|
||||
});
|
||||
}
|
||||
|
||||
class TileFailed extends TileDownloadEvent {
|
||||
final TileCoord coord;
|
||||
final String error;
|
||||
TileFailed(this.coord, this.error);
|
||||
}
|
||||
|
||||
class TileDownloadComplete extends TileDownloadEvent {
|
||||
final int downloaded;
|
||||
final int skipped;
|
||||
final int failed;
|
||||
final int total;
|
||||
TileDownloadComplete({
|
||||
required this.downloaded,
|
||||
required this.skipped,
|
||||
required this.failed,
|
||||
required this.total,
|
||||
});
|
||||
}
|
||||
|
||||
class TileBatchSkipped extends TileDownloadEvent {
|
||||
final int count;
|
||||
final int total;
|
||||
TileBatchSkipped({required this.count, required this.total});
|
||||
}
|
||||
|
||||
class TileDownloadCancelled extends TileDownloadEvent {}
|
||||
|
||||
/// Downloads map tiles for given polygons and zoom levels.
|
||||
class TileDownloadService {
|
||||
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||
final http.Client _httpClient = http.Client();
|
||||
|
||||
bool _cancelled = false;
|
||||
|
||||
/// Cancel an ongoing download.
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
|
||||
/// Download tiles for the given polygons and zoom range.
|
||||
///
|
||||
/// Returns a stream of [TileDownloadEvent]s.
|
||||
/// [urlTemplate] should contain `{z}`, `{x}`, `{y}` placeholders,
|
||||
/// and optionally `{s}` for subdomains.
|
||||
Stream<TileDownloadEvent> downloadTiles({
|
||||
required List<List<LatLng>> polygons,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String urlTemplate,
|
||||
String? displayName,
|
||||
Map<String, String>? headers,
|
||||
int maxConcurrency = 6,
|
||||
int rateLimit = 30,
|
||||
}) {
|
||||
final controller = StreamController<TileDownloadEvent>();
|
||||
|
||||
_runDownload(
|
||||
controller: controller,
|
||||
polygons: polygons,
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
urlTemplate: urlTemplate,
|
||||
displayName: displayName,
|
||||
headers: headers,
|
||||
maxConcurrency: maxConcurrency,
|
||||
rateLimit: rateLimit,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<void> _runDownload({
|
||||
required StreamController<TileDownloadEvent> controller,
|
||||
required List<List<LatLng>> polygons,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
required String urlTemplate,
|
||||
String? displayName,
|
||||
Map<String, String>? headers,
|
||||
required int maxConcurrency,
|
||||
required int rateLimit,
|
||||
}) async {
|
||||
_cancelled = false;
|
||||
|
||||
final styleHash = _cache.styleHashFromUrl(urlTemplate);
|
||||
|
||||
// Save style metadata with download region so it can be reused
|
||||
final region = DownloadRegion(
|
||||
polygons: polygons
|
||||
.map((poly) =>
|
||||
poly.map((p) => [p.latitude, p.longitude]).toList())
|
||||
.toList(),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
await _cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: displayName ?? urlTemplate,
|
||||
urlTemplate: urlTemplate,
|
||||
region: region,
|
||||
);
|
||||
|
||||
final allTiles =
|
||||
TileMathService.getTilesForPolygons(polygons, minZoom, maxZoom);
|
||||
|
||||
// Load manifest once and partition tiles into needed vs already cached
|
||||
final manifest = await _cache.loadManifest(styleHash);
|
||||
final tilesToDownload = <TileCoord>[];
|
||||
var skipped = 0;
|
||||
|
||||
for (final tile in allTiles) {
|
||||
final key = '${tile.z}/${tile.x}/${tile.y}';
|
||||
if (manifest.contains(key)) {
|
||||
skipped++;
|
||||
} else {
|
||||
tilesToDownload.add(tile);
|
||||
}
|
||||
}
|
||||
|
||||
final total = allTiles.length;
|
||||
controller.add(TileDownloadStarted(total));
|
||||
|
||||
// Report all skipped tiles immediately (no per-tile filesystem check)
|
||||
if (skipped > 0) {
|
||||
controller.add(TileBatchSkipped(count: skipped, total: total));
|
||||
}
|
||||
|
||||
if (tilesToDownload.isEmpty) {
|
||||
controller.add(TileDownloadComplete(
|
||||
downloaded: 0, skipped: skipped, failed: 0, total: total));
|
||||
await controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
var downloaded = 0;
|
||||
var failed = 0;
|
||||
|
||||
final semaphore = _Semaphore(maxConcurrency);
|
||||
final rateLimiter = _RateLimiter(rateLimit);
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
for (final tile in tilesToDownload) {
|
||||
if (_cancelled) break;
|
||||
|
||||
await rateLimiter.wait();
|
||||
if (_cancelled) break;
|
||||
|
||||
await semaphore.acquire();
|
||||
if (_cancelled) {
|
||||
semaphore.release();
|
||||
break;
|
||||
}
|
||||
|
||||
final future = _downloadSingleTile(tile, urlTemplate, styleHash, headers)
|
||||
.then((event) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(event);
|
||||
if (event is TileDownloaded) {
|
||||
downloaded++;
|
||||
} else if (event is TileFailed) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
semaphore.release();
|
||||
});
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
// Wait for all in-flight downloads to finish
|
||||
await Future.wait(futures);
|
||||
|
||||
if (_cancelled) {
|
||||
controller.add(TileDownloadCancelled());
|
||||
} else {
|
||||
controller.add(TileDownloadComplete(
|
||||
downloaded: downloaded,
|
||||
skipped: skipped,
|
||||
failed: failed,
|
||||
total: total,
|
||||
));
|
||||
}
|
||||
|
||||
await controller.close();
|
||||
}
|
||||
|
||||
Future<TileDownloadEvent> _downloadSingleTile(
|
||||
TileCoord tile,
|
||||
String urlTemplate,
|
||||
String styleHash,
|
||||
Map<String, String>? extraHeaders,
|
||||
) async {
|
||||
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
|
||||
|
||||
// Build URL
|
||||
final subdomains = ['a', 'b', 'c'];
|
||||
var url = urlTemplate
|
||||
.replaceAll('{s}', subdomains[tile.x % 3])
|
||||
.replaceAll('{z}', '${tile.z}')
|
||||
.replaceAll('{x}', '${tile.x}')
|
||||
.replaceAll('{y}', '${tile.y}');
|
||||
|
||||
// Download with retries
|
||||
const maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (_cancelled) {
|
||||
return TileFailed(tile, 'Cancelled');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _httpClient.get(
|
||||
Uri.parse(url),
|
||||
headers: {'User-Agent': 'MeshCoreSAR/1.0', ...?extraHeaders},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
continue;
|
||||
}
|
||||
return TileFailed(tile, 'HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
await _cache.putTile(
|
||||
styleHash,
|
||||
tile.z,
|
||||
tile.x,
|
||||
tile.y,
|
||||
response.bodyBytes,
|
||||
contentType: response.headers['content-type'],
|
||||
sourceUrl: url,
|
||||
);
|
||||
|
||||
return TileDownloaded(
|
||||
north: bounds.north,
|
||||
south: bounds.south,
|
||||
east: bounds.east,
|
||||
west: bounds.west,
|
||||
);
|
||||
} catch (e) {
|
||||
if (attempt < maxRetries - 1) {
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
continue;
|
||||
}
|
||||
return TileFailed(tile, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return TileFailed(tile, 'Max retries exceeded');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cancelled = true;
|
||||
_httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int maxCount;
|
||||
int _currentCount = 0;
|
||||
final _waitQueue = <Completer<void>>[];
|
||||
|
||||
_Semaphore(this.maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waitQueue.isNotEmpty) {
|
||||
_waitQueue.removeAt(0).complete();
|
||||
} else {
|
||||
_currentCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiter that ensures no more than [maxPerSecond] operations per second.
|
||||
class _RateLimiter {
|
||||
final int maxPerSecond;
|
||||
final _timestamps = <DateTime>[];
|
||||
|
||||
_RateLimiter(this.maxPerSecond);
|
||||
|
||||
Future<void> wait() async {
|
||||
final now = DateTime.now();
|
||||
_timestamps
|
||||
.removeWhere((t) => now.difference(t) > const Duration(seconds: 1));
|
||||
|
||||
if (_timestamps.length >= maxPerSecond) {
|
||||
final oldest = _timestamps.first;
|
||||
final waitTime = const Duration(seconds: 1) - now.difference(oldest);
|
||||
if (waitTime > Duration.zero) {
|
||||
await Future.delayed(waitTime);
|
||||
}
|
||||
_timestamps.removeAt(0);
|
||||
}
|
||||
_timestamps.add(DateTime.now());
|
||||
}
|
||||
}
|
||||
268
lib/services/tile_math_service.dart
Normal file
268
lib/services/tile_math_service.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// A tile coordinate with x, y, and zoom level.
|
||||
class TileCoord {
|
||||
final int x;
|
||||
final int y;
|
||||
final int z;
|
||||
|
||||
const TileCoord(this.x, this.y, this.z);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is TileCoord && other.x == x && other.y == y && other.z == z;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(x, y, z);
|
||||
|
||||
@override
|
||||
String toString() => 'TileCoord($z/$x/$y)';
|
||||
}
|
||||
|
||||
/// A geographical bounding box.
|
||||
class TileBounds {
|
||||
final double north;
|
||||
final double south;
|
||||
final double east;
|
||||
final double west;
|
||||
|
||||
const TileBounds({
|
||||
required this.north,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.west,
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure math utilities for slippy map tile calculations.
|
||||
///
|
||||
/// Ported from the Go offline-map-tile-downloader.
|
||||
class TileMathService {
|
||||
const TileMathService._();
|
||||
|
||||
/// Convert latitude/longitude to tile coordinates at the given zoom level.
|
||||
static (int x, int y) latLonToTile(double lat, double lon, int zoom) {
|
||||
final latRad = lat * pi / 180;
|
||||
final n = pow(2, zoom).toDouble();
|
||||
final x = (n * ((lon + 180) / 360)).floor();
|
||||
final y =
|
||||
(n * (1 - (log(tan(latRad) + 1 / cos(latRad)) / pi)) / 2).floor();
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
/// Calculate the geographical bounding box of a tile.
|
||||
static TileBounds tileBounds(int x, int y, int z) {
|
||||
final n = pow(2.0, z).toDouble();
|
||||
final lonDeg = x / n * 360.0 - 180.0;
|
||||
final latRad = atan(sinh(pi * (1 - 2 * y / n)));
|
||||
final latDeg = latRad * 180.0 / pi;
|
||||
|
||||
final lon2Deg = (x + 1) / n * 360.0 - 180.0;
|
||||
final lat2Rad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
|
||||
final lat2Deg = lat2Rad * 180.0 / pi;
|
||||
|
||||
return TileBounds(
|
||||
north: latDeg,
|
||||
south: lat2Deg,
|
||||
east: lon2Deg,
|
||||
west: lonDeg,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hyperbolic sine.
|
||||
static double sinh(double x) => (exp(x) - exp(-x)) / 2;
|
||||
|
||||
/// Check if a point is inside a polygon using the ray casting algorithm.
|
||||
static bool polygonContains(List<LatLng> poly, LatLng point) {
|
||||
var inside = false;
|
||||
for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
if ((poly[i].latitude > point.latitude) !=
|
||||
(poly[j].latitude > point.latitude) &&
|
||||
(point.longitude <
|
||||
(poly[j].longitude - poly[i].longitude) *
|
||||
(point.latitude - poly[i].latitude) /
|
||||
(poly[j].latitude - poly[i].latitude) +
|
||||
poly[i].longitude)) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/// Check if a bounding box contains a point.
|
||||
static bool boundsContains(TileBounds bounds, LatLng point) {
|
||||
return point.latitude <= bounds.north &&
|
||||
point.latitude >= bounds.south &&
|
||||
point.longitude >= bounds.west &&
|
||||
point.longitude <= bounds.east;
|
||||
}
|
||||
|
||||
/// Check if a polygon intersects with a tile bounding box.
|
||||
static bool polygonIntersects(List<LatLng> poly, TileBounds bounds) {
|
||||
// Check if any polygon vertex is inside the tile
|
||||
for (final p in poly) {
|
||||
if (boundsContains(bounds, p)) return true;
|
||||
}
|
||||
|
||||
// Check if any tile corner is inside the polygon
|
||||
final corners = [
|
||||
LatLng(bounds.north, bounds.west),
|
||||
LatLng(bounds.north, bounds.east),
|
||||
LatLng(bounds.south, bounds.west),
|
||||
LatLng(bounds.south, bounds.east),
|
||||
];
|
||||
for (final corner in corners) {
|
||||
if (polygonContains(poly, corner)) return true;
|
||||
}
|
||||
|
||||
// Check if any polygon edge intersects any tile edge
|
||||
final tileEdges = [
|
||||
(corners[0], corners[1]),
|
||||
(corners[1], corners[3]),
|
||||
(corners[3], corners[2]),
|
||||
(corners[2], corners[0]),
|
||||
];
|
||||
for (int i = 0; i < poly.length; i++) {
|
||||
final p1 = poly[i];
|
||||
final p2 = poly[(i + 1) % poly.length];
|
||||
for (final edge in tileEdges) {
|
||||
if (_lineIntersects(p1, p2, edge.$1, edge.$2)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if two line segments intersect.
|
||||
static bool _lineIntersects(LatLng p1, LatLng q1, LatLng p2, LatLng q2) {
|
||||
final o1 = _orientation(p1, q1, p2);
|
||||
final o2 = _orientation(p1, q1, q2);
|
||||
final o3 = _orientation(p2, q2, p1);
|
||||
final o4 = _orientation(p2, q2, q1);
|
||||
|
||||
if (o1 != o2 && o3 != o4) return true;
|
||||
|
||||
if (o1 == 0 && _onSegment(p1, p2, q1)) return true;
|
||||
if (o2 == 0 && _onSegment(p1, q2, q1)) return true;
|
||||
if (o3 == 0 && _onSegment(p2, p1, q2)) return true;
|
||||
if (o4 == 0 && _onSegment(p2, q1, q2)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find orientation of ordered triplet (p, q, r).
|
||||
/// Returns 0 for collinear, 1 for clockwise, 2 for counterclockwise.
|
||||
static int _orientation(LatLng p, LatLng q, LatLng r) {
|
||||
final val = (q.longitude - p.longitude) * (r.latitude - q.latitude) -
|
||||
(q.latitude - p.latitude) * (r.longitude - q.longitude);
|
||||
if (val == 0) return 0;
|
||||
return val > 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
/// Check if point q lies on segment pr.
|
||||
static bool _onSegment(LatLng p, LatLng q, LatLng r) {
|
||||
return q.latitude <= max(p.latitude, r.latitude) &&
|
||||
q.latitude >= min(p.latitude, r.latitude) &&
|
||||
q.longitude <= max(p.longitude, r.longitude) &&
|
||||
q.longitude >= min(p.longitude, r.longitude);
|
||||
}
|
||||
|
||||
/// Get all tiles that overlap with the given polygons across zoom levels.
|
||||
static List<TileCoord> getTilesForPolygons(
|
||||
List<List<LatLng>> polygons,
|
||||
int minZoom,
|
||||
int maxZoom,
|
||||
) {
|
||||
final tileSet = <TileCoord>{};
|
||||
|
||||
for (final poly in polygons) {
|
||||
if (poly.length < 3) continue;
|
||||
|
||||
// Find bounding box of polygon
|
||||
var minLat = 90.0, minLon = 180.0;
|
||||
var maxLat = -90.0, maxLon = -180.0;
|
||||
for (final p in poly) {
|
||||
if (p.latitude < minLat) minLat = p.latitude;
|
||||
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||
if (p.longitude < minLon) minLon = p.longitude;
|
||||
if (p.longitude > maxLon) maxLon = p.longitude;
|
||||
}
|
||||
|
||||
for (int z = minZoom; z <= maxZoom; z++) {
|
||||
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
|
||||
final (brx, bry) = latLonToTile(minLat, maxLon, z);
|
||||
|
||||
for (int x = tlx; x <= brx; x++) {
|
||||
for (int y = tly; y <= bry; y++) {
|
||||
final tile = TileCoord(x, y, z);
|
||||
if (tileSet.contains(tile)) continue;
|
||||
|
||||
final bounds = tileBounds(x, y, z);
|
||||
|
||||
// Check if all tile corners are inside the polygon
|
||||
final allCornersInside = polygonContains(
|
||||
poly, LatLng(bounds.north, bounds.west)) &&
|
||||
polygonContains(poly, LatLng(bounds.north, bounds.east)) &&
|
||||
polygonContains(poly, LatLng(bounds.south, bounds.west)) &&
|
||||
polygonContains(poly, LatLng(bounds.south, bounds.east));
|
||||
if (allCornersInside) {
|
||||
tileSet.add(tile);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if all polygon vertices are inside the tile
|
||||
var polyInTile = true;
|
||||
for (final p in poly) {
|
||||
if (!boundsContains(bounds, p)) {
|
||||
polyInTile = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (polyInTile) {
|
||||
tileSet.add(tile);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for intersection
|
||||
if (polygonIntersects(poly, bounds)) {
|
||||
tileSet.add(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tileSet.toList();
|
||||
}
|
||||
|
||||
/// Estimate the number of tiles for given polygons and zoom range.
|
||||
/// Faster than getTilesForPolygons — uses bounding box approximation.
|
||||
static int estimateTileCount(
|
||||
List<List<LatLng>> polygons,
|
||||
int minZoom,
|
||||
int maxZoom,
|
||||
) {
|
||||
var count = 0;
|
||||
for (final poly in polygons) {
|
||||
if (poly.length < 3) continue;
|
||||
|
||||
var minLat = 90.0, minLon = 180.0;
|
||||
var maxLat = -90.0, maxLon = -180.0;
|
||||
for (final p in poly) {
|
||||
if (p.latitude < minLat) minLat = p.latitude;
|
||||
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||
if (p.longitude < minLon) minLon = p.longitude;
|
||||
if (p.longitude > maxLon) maxLon = p.longitude;
|
||||
}
|
||||
|
||||
for (int z = minZoom; z <= maxZoom; z++) {
|
||||
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
|
||||
final (brx, bry) = latLonToTile(minLat, maxLon, z);
|
||||
count += (brx - tlx + 1) * (bry - tly + 1);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
541
lib/services/tile_sharing_service.dart
Normal file
541
lib/services/tile_sharing_service.dart
Normal file
@@ -0,0 +1,541 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nsd/nsd.dart' as nsd;
|
||||
|
||||
import 'offline_tile_cache_service.dart';
|
||||
|
||||
/// A discovered tile-serving peer on the local network.
|
||||
class TilePeer {
|
||||
final String ipAddress;
|
||||
final int port;
|
||||
|
||||
const TilePeer({required this.ipAddress, required this.port});
|
||||
|
||||
String get baseUrl => 'http://$ipAddress:$port';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is TilePeer && other.ipAddress == ipAddress && other.port == port;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(ipAddress, port);
|
||||
|
||||
@override
|
||||
String toString() => 'TilePeer($ipAddress:$port)';
|
||||
}
|
||||
|
||||
/// What a remote peer has available.
|
||||
class PeerCatalog {
|
||||
final TilePeer peer;
|
||||
final List<StyleInfo> styles;
|
||||
|
||||
const PeerCatalog({required this.peer, required this.styles});
|
||||
}
|
||||
|
||||
class PeerTileResponse {
|
||||
final Uint8List bytes;
|
||||
final String? contentType;
|
||||
|
||||
const PeerTileResponse({
|
||||
required this.bytes,
|
||||
required this.contentType,
|
||||
});
|
||||
}
|
||||
|
||||
/// Progress events during a P2P sync.
|
||||
sealed class PeerSyncEvent {}
|
||||
|
||||
class PeerSyncStarted extends PeerSyncEvent {
|
||||
final int totalTiles;
|
||||
PeerSyncStarted(this.totalTiles);
|
||||
}
|
||||
|
||||
class PeerSyncTileDownloaded extends PeerSyncEvent {
|
||||
final int downloaded;
|
||||
final int total;
|
||||
PeerSyncTileDownloaded({required this.downloaded, required this.total});
|
||||
}
|
||||
|
||||
class PeerSyncTileSkipped extends PeerSyncEvent {
|
||||
final int skipped;
|
||||
final int total;
|
||||
PeerSyncTileSkipped({required this.skipped, required this.total});
|
||||
}
|
||||
|
||||
class PeerSyncComplete extends PeerSyncEvent {
|
||||
final int downloaded;
|
||||
final int skipped;
|
||||
final int failed;
|
||||
PeerSyncComplete({
|
||||
required this.downloaded,
|
||||
required this.skipped,
|
||||
required this.failed,
|
||||
});
|
||||
}
|
||||
|
||||
class PeerSyncCancelled extends PeerSyncEvent {}
|
||||
|
||||
/// HTTP server that serves cached tiles to other devices on the
|
||||
/// local network, with mDNS advertisement, peer discovery, and P2P sync.
|
||||
///
|
||||
/// Protocol:
|
||||
/// GET /styles → JSON array of StyleInfo
|
||||
/// GET /tiles/{hash}/list → JSON array of {z, x, y}
|
||||
/// GET /tiles/{hash}/{z}/{x}/{y} → tile bytes | 404
|
||||
class TileSharingService {
|
||||
TileSharingService._();
|
||||
static final instance = TileSharingService._();
|
||||
|
||||
static const int defaultPort = 8347;
|
||||
static const String serviceType = '_sartiles._tcp';
|
||||
|
||||
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||
final http.Client _httpClient = http.Client();
|
||||
|
||||
HttpServer? _server;
|
||||
nsd.Discovery? _activeDiscovery;
|
||||
nsd.Registration? _activeRegistration;
|
||||
|
||||
final _peersController = StreamController<Set<TilePeer>>.broadcast();
|
||||
final Set<TilePeer> _discoveredPeers = {};
|
||||
|
||||
bool _syncCancelled = false;
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
Stream<Set<TilePeer>> get peersStream => _peersController.stream;
|
||||
Set<TilePeer> get discoveredPeers => Set.unmodifiable(_discoveredPeers);
|
||||
|
||||
// ── Server ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> startServer() async {
|
||||
if (_server != null) return;
|
||||
|
||||
try {
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, defaultPort);
|
||||
debugPrint('[TileSharing] Server started on port $defaultPort');
|
||||
|
||||
_server!.listen(_handleRequest, onError: (error) {
|
||||
debugPrint('[TileSharing] Server error: $error');
|
||||
});
|
||||
|
||||
await _advertise();
|
||||
} catch (e) {
|
||||
debugPrint('[TileSharing] Failed to start server: $e');
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
await _stopAdvertising();
|
||||
await _server?.close();
|
||||
_server = null;
|
||||
debugPrint('[TileSharing] Server stopped');
|
||||
}
|
||||
|
||||
// ── Discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> startDiscovery() async {
|
||||
if (_activeDiscovery != null) return;
|
||||
|
||||
try {
|
||||
_activeDiscovery = await nsd.startDiscovery(serviceType);
|
||||
_activeDiscovery!.addServiceListener((service, status) {
|
||||
if (service.host == null || service.port == null) return;
|
||||
|
||||
final peer = TilePeer(
|
||||
ipAddress: service.host!,
|
||||
port: service.port!,
|
||||
);
|
||||
|
||||
if (status == nsd.ServiceStatus.found) {
|
||||
_discoveredPeers.add(peer);
|
||||
} else {
|
||||
_discoveredPeers.remove(peer);
|
||||
}
|
||||
_peersController.add(Set.unmodifiable(_discoveredPeers));
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[TileSharing] Discovery error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopPeerDiscovery() async {
|
||||
if (_activeDiscovery != null) {
|
||||
await nsd.stopDiscovery(_activeDiscovery!);
|
||||
_activeDiscovery = null;
|
||||
}
|
||||
_discoveredPeers.clear();
|
||||
_peersController.add(const {});
|
||||
}
|
||||
|
||||
void addManualPeer(String ipAddress, {int port = defaultPort}) {
|
||||
_discoveredPeers.add(TilePeer(ipAddress: ipAddress, port: port));
|
||||
_peersController.add(Set.unmodifiable(_discoveredPeers));
|
||||
}
|
||||
|
||||
void removePeer(TilePeer peer) {
|
||||
_discoveredPeers.remove(peer);
|
||||
_peersController.add(Set.unmodifiable(_discoveredPeers));
|
||||
}
|
||||
|
||||
// ── Peer queries ────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetch the catalog (available styles + tile counts) from a peer.
|
||||
Future<PeerCatalog?> fetchPeerCatalog(TilePeer peer) async {
|
||||
try {
|
||||
final uri = Uri.parse('${peer.baseUrl}/styles');
|
||||
final response =
|
||||
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
final styles = data
|
||||
.map((e) => StyleInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return PeerCatalog(peer: peer, styles: styles);
|
||||
} catch (e) {
|
||||
debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch catalogs from all discovered peers.
|
||||
Future<List<PeerCatalog>> fetchAllPeerCatalogs() async {
|
||||
final futures =
|
||||
_discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList();
|
||||
final results = await Future.wait(futures);
|
||||
return results.whereType<PeerCatalog>().toList();
|
||||
}
|
||||
|
||||
/// Fetch the tile list for a style from a peer.
|
||||
Future<List<CachedTileCoord>?> fetchPeerTileList(
|
||||
TilePeer peer,
|
||||
String styleHash,
|
||||
) async {
|
||||
try {
|
||||
final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/list');
|
||||
final response =
|
||||
await _httpClient.get(uri).timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final List<dynamic> data = jsonDecode(response.body);
|
||||
return data
|
||||
.map((e) => CachedTileCoord.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('[TileSharing] fetchPeerTileList(${peer.ipAddress}): $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a single tile from a peer. Returns raw tile bytes or null.
|
||||
Future<PeerTileResponse?> fetchTileFromPeer(
|
||||
TilePeer peer,
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
) async {
|
||||
try {
|
||||
final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y');
|
||||
final response =
|
||||
await _httpClient.get(uri).timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode == 200) {
|
||||
return PeerTileResponse(
|
||||
bytes: response.bodyBytes,
|
||||
contentType: response.headers['content-type'],
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail — caller will try next peer
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Try fetching a tile from any available peer (for the caching provider).
|
||||
Future<PeerTileResponse?> fetchFromAnyPeer(
|
||||
String styleHash,
|
||||
int z,
|
||||
int x,
|
||||
int y,
|
||||
) async {
|
||||
for (final peer in _discoveredPeers) {
|
||||
final tile = await fetchTileFromPeer(peer, styleHash, z, x, y);
|
||||
if (tile != null) return tile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── P2P Sync ────────────────────────────────────────────────────────────
|
||||
|
||||
void cancelSync() {
|
||||
_syncCancelled = true;
|
||||
}
|
||||
|
||||
/// Sync a style from peers: fetch their tile list, download tiles we
|
||||
/// don't have, trying multiple peers in round-robin for speed.
|
||||
///
|
||||
/// [peers] — which peers to pull from (all that have this style).
|
||||
/// [styleHash] — which style to sync.
|
||||
/// [styleMeta] — metadata to save locally (name, URL template).
|
||||
Stream<PeerSyncEvent> syncStyleFromPeers({
|
||||
required List<TilePeer> peers,
|
||||
required String styleHash,
|
||||
required StyleInfo styleMeta,
|
||||
int maxConcurrency = 8,
|
||||
}) {
|
||||
final controller = StreamController<PeerSyncEvent>();
|
||||
_runSync(
|
||||
controller: controller,
|
||||
peers: peers,
|
||||
styleHash: styleHash,
|
||||
styleMeta: styleMeta,
|
||||
maxConcurrency: maxConcurrency,
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<void> _runSync({
|
||||
required StreamController<PeerSyncEvent> controller,
|
||||
required List<TilePeer> peers,
|
||||
required String styleHash,
|
||||
required StyleInfo styleMeta,
|
||||
required int maxConcurrency,
|
||||
}) async {
|
||||
_syncCancelled = false;
|
||||
|
||||
// Save style metadata locally
|
||||
await _cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: styleMeta.displayName,
|
||||
urlTemplate: styleMeta.urlTemplate,
|
||||
region: styleMeta.region,
|
||||
);
|
||||
|
||||
// Collect tile lists from all peers and merge (union)
|
||||
final allTiles = <String, CachedTileCoord>{};
|
||||
for (final peer in peers) {
|
||||
if (_syncCancelled) break;
|
||||
final tiles = await fetchPeerTileList(peer, styleHash);
|
||||
if (tiles != null) {
|
||||
for (final t in tiles) {
|
||||
allTiles['${t.z}/${t.x}/${t.y}'] = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final tilesToSync = allTiles.values.toList();
|
||||
controller.add(PeerSyncStarted(tilesToSync.length));
|
||||
|
||||
if (tilesToSync.isEmpty || _syncCancelled) {
|
||||
controller
|
||||
.add(PeerSyncComplete(downloaded: 0, skipped: 0, failed: 0));
|
||||
await controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
var downloaded = 0;
|
||||
var skipped = 0;
|
||||
var failed = 0;
|
||||
final total = tilesToSync.length;
|
||||
|
||||
final semaphore = _Semaphore(maxConcurrency);
|
||||
final futures = <Future<void>>[];
|
||||
var peerIndex = 0;
|
||||
|
||||
for (final tile in tilesToSync) {
|
||||
if (_syncCancelled) break;
|
||||
|
||||
await semaphore.acquire();
|
||||
if (_syncCancelled) {
|
||||
semaphore.release();
|
||||
break;
|
||||
}
|
||||
|
||||
// Round-robin across peers for parallel throughput
|
||||
final peer = peers[peerIndex % peers.length];
|
||||
peerIndex++;
|
||||
|
||||
final future = () async {
|
||||
try {
|
||||
// Skip if we already have it
|
||||
if (await _cache.hasTile(styleHash, tile.z, tile.x, tile.y)) {
|
||||
skipped++;
|
||||
controller.add(
|
||||
PeerSyncTileSkipped(skipped: skipped, total: total));
|
||||
return;
|
||||
}
|
||||
|
||||
// Try this peer, then fallback to others
|
||||
PeerTileResponse? tileResponse =
|
||||
await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y);
|
||||
if (tileResponse == null) {
|
||||
for (final fallback in peers) {
|
||||
if (fallback == peer) continue;
|
||||
tileResponse = await fetchTileFromPeer(
|
||||
fallback, styleHash, tile.z, tile.x, tile.y);
|
||||
if (tileResponse != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tileResponse != null) {
|
||||
await _cache.putRawTile(
|
||||
styleHash,
|
||||
tile.z,
|
||||
tile.x,
|
||||
tile.y,
|
||||
tileResponse.bytes,
|
||||
contentType: tileResponse.contentType,
|
||||
);
|
||||
downloaded++;
|
||||
controller.add(PeerSyncTileDownloaded(
|
||||
downloaded: downloaded, total: total));
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (_) {
|
||||
failed++;
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
}();
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (_syncCancelled) {
|
||||
controller.add(PeerSyncCancelled());
|
||||
} else {
|
||||
controller.add(PeerSyncComplete(
|
||||
downloaded: downloaded,
|
||||
skipped: skipped,
|
||||
failed: failed,
|
||||
));
|
||||
}
|
||||
await controller.close();
|
||||
}
|
||||
|
||||
// ── mDNS ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _advertise() async {
|
||||
try {
|
||||
final styles = await _cache.listStyles();
|
||||
_activeRegistration = await nsd.register(nsd.Service(
|
||||
name: 'MeshCore SAR Tiles',
|
||||
type: serviceType,
|
||||
port: defaultPort,
|
||||
txt: {
|
||||
'styles':
|
||||
Uint8List.fromList(utf8.encode(styles.join(','))),
|
||||
},
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('[TileSharing] mDNS registration error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopAdvertising() async {
|
||||
if (_activeRegistration != null) {
|
||||
await nsd.unregister(_activeRegistration!);
|
||||
_activeRegistration = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP Server ─────────────────────────────────────────────────────────
|
||||
|
||||
void _handleRequest(HttpRequest request) async {
|
||||
request.response.headers.add('Access-Control-Allow-Origin', '*');
|
||||
|
||||
final path = request.uri.path;
|
||||
|
||||
// GET /styles → detailed style list
|
||||
if (path == '/styles') {
|
||||
final styles = await _cache.listStylesDetailed();
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode(styles.map((s) => s.toJson()).toList()));
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /tiles/{hash}/list → tile coordinate inventory
|
||||
final listPattern = RegExp(r'^/tiles/([a-f0-9]+)/list$');
|
||||
final listMatch = listPattern.firstMatch(path);
|
||||
if (listMatch != null) {
|
||||
final styleHash = listMatch.group(1)!;
|
||||
final tiles = await _cache.listTilesForStyle(styleHash);
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode(tiles.map((t) => t.toJson()).toList()));
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /tiles/{hash}/{z}/{x}/{y} → tile bytes
|
||||
final tilePattern =
|
||||
RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)(?:\.avif)?$');
|
||||
final tileMatch = tilePattern.firstMatch(path);
|
||||
if (tileMatch != null) {
|
||||
final styleHash = tileMatch.group(1)!;
|
||||
final z = int.parse(tileMatch.group(2)!);
|
||||
final x = int.parse(tileMatch.group(3)!);
|
||||
final y = int.parse(tileMatch.group(4)!);
|
||||
|
||||
final tile = await _cache.getTileData(styleHash, z, x, y);
|
||||
if (tile != null) {
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = tile.contentType == null
|
||||
? ContentType.binary
|
||||
: ContentType.parse(tile.contentType!)
|
||||
..add(tile.bytes);
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stopServer();
|
||||
stopPeerDiscovery();
|
||||
_httpClient.close();
|
||||
_peersController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int maxCount;
|
||||
int _currentCount = 0;
|
||||
final _waitQueue = <Completer<void>>[];
|
||||
|
||||
_Semaphore(this.maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waitQueue.isNotEmpty) {
|
||||
_waitQueue.removeAt(0).complete();
|
||||
} else {
|
||||
_currentCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
508
lib/services/traffic_stats_reporting_service.dart
Normal file
508
lib/services/traffic_stats_reporting_service.dart
Normal file
@@ -0,0 +1,508 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/ble_packet_log.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
import 'live_traffic_summary.dart';
|
||||
|
||||
class TrafficStatsCounts {
|
||||
static const List<String> packetTypeKeys = <String>[
|
||||
'pt_00',
|
||||
'pt_01',
|
||||
'pt_02',
|
||||
'pt_03',
|
||||
'pt_04',
|
||||
'pt_05',
|
||||
'pt_06',
|
||||
'pt_07',
|
||||
'pt_08',
|
||||
'pt_09',
|
||||
'pt_0a',
|
||||
'pt_0b',
|
||||
'pt_0c',
|
||||
'pt_0d',
|
||||
'pt_0e',
|
||||
'pt_0f',
|
||||
];
|
||||
static const List<String> pathModeKeys = <String>[
|
||||
'path_mode_1b',
|
||||
'path_mode_2b',
|
||||
'path_mode_3b',
|
||||
'path_mode_none',
|
||||
'path_mode_unknown',
|
||||
];
|
||||
static const String decodeFailKey = 'decode_fail';
|
||||
static const List<String> allKeys = <String>[
|
||||
...packetTypeKeys,
|
||||
decodeFailKey,
|
||||
...pathModeKeys,
|
||||
];
|
||||
|
||||
final Map<String, int> _values;
|
||||
|
||||
TrafficStatsCounts._(this._values);
|
||||
|
||||
factory TrafficStatsCounts.empty() {
|
||||
return TrafficStatsCounts._(
|
||||
<String, int>{for (final key in allKeys) key: 0},
|
||||
);
|
||||
}
|
||||
|
||||
factory TrafficStatsCounts.fromJson(Map<String, dynamic>? json) {
|
||||
final counts = TrafficStatsCounts.empty();
|
||||
if (json == null) {
|
||||
return counts;
|
||||
}
|
||||
for (final key in allKeys) {
|
||||
counts._values[key] = (json[key] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
int operator [](String key) => _values[key] ?? 0;
|
||||
|
||||
bool get isEmpty => _values.values.every((value) => value == 0);
|
||||
|
||||
Map<String, int> toJson() {
|
||||
return <String, int>{
|
||||
for (final key in allKeys) key: _values[key] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
void increment(String key, [int amount = 1]) {
|
||||
_values[key] = (_values[key] ?? 0) + amount;
|
||||
}
|
||||
|
||||
void incrementPacketType(int payloadType) {
|
||||
if (payloadType < 0 || payloadType > 0x0F) {
|
||||
increment(decodeFailKey);
|
||||
return;
|
||||
}
|
||||
increment('pt_${payloadType.toRadixString(16).padLeft(2, '0')}');
|
||||
}
|
||||
|
||||
void mergeFrom(TrafficStatsCounts other) {
|
||||
for (final key in allKeys) {
|
||||
increment(key, other[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TrafficStatsQueuedReport {
|
||||
final String reportId;
|
||||
final String deviceKey6;
|
||||
final DateTime windowStart;
|
||||
final DateTime windowEnd;
|
||||
final String appVersion;
|
||||
final TrafficStatsCounts counts;
|
||||
|
||||
const TrafficStatsQueuedReport({
|
||||
required this.reportId,
|
||||
required this.deviceKey6,
|
||||
required this.windowStart,
|
||||
required this.windowEnd,
|
||||
required this.appVersion,
|
||||
required this.counts,
|
||||
});
|
||||
|
||||
factory TrafficStatsQueuedReport.fromJson(Map<String, dynamic> json) {
|
||||
return TrafficStatsQueuedReport(
|
||||
reportId: (json['reportId'] as String?) ?? '',
|
||||
deviceKey6: (json['deviceKey6'] as String?) ?? '',
|
||||
windowStart: DateTime.parse(json['windowStart'] as String).toUtc(),
|
||||
windowEnd: DateTime.parse(json['windowEnd'] as String).toUtc(),
|
||||
appVersion: (json['appVersion'] as String?) ?? 'unknown',
|
||||
counts: TrafficStatsCounts.fromJson(
|
||||
json['counts'] as Map<String, dynamic>?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'reportId': reportId,
|
||||
'deviceKey6': deviceKey6,
|
||||
'windowStart': windowStart.toIso8601String(),
|
||||
'windowEnd': windowEnd.toIso8601String(),
|
||||
'appVersion': appVersion,
|
||||
'counts': counts.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TrafficStatsReportingService extends ChangeNotifier {
|
||||
static const String workerBaseUrl = 'https://mcstats.dz0ny.dev';
|
||||
static final Uri dashboardUri = Uri.parse(workerBaseUrl);
|
||||
static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest');
|
||||
|
||||
static const bool defaultEnabled = true;
|
||||
static const int defaultIntervalMinutes = 5;
|
||||
static const String _enabledKey = 'traffic_stats_reporting_enabled';
|
||||
static const String _legacyIntervalKey =
|
||||
'traffic_stats_reporting_interval_minutes';
|
||||
static const String _queueKey = 'traffic_stats_reporting_queue';
|
||||
static const String _lastSuccessAtKey =
|
||||
'traffic_stats_reporting_last_success_at';
|
||||
static const String _lastErrorKey = 'traffic_stats_reporting_last_error';
|
||||
static const Duration _retryInterval = Duration(seconds: 30);
|
||||
|
||||
final http.Client _client;
|
||||
final bool _ownsClient;
|
||||
final DateTime Function() _now;
|
||||
final Future<SharedPreferences> Function() _prefsProvider;
|
||||
final Future<String> Function() _appVersionProvider;
|
||||
final Map<int, TrafficStatsCounts> _openWindows =
|
||||
<int, TrafficStatsCounts>{};
|
||||
final List<TrafficStatsQueuedReport> _queue = <TrafficStatsQueuedReport>[];
|
||||
|
||||
String? Function()? _deviceKey6Provider;
|
||||
Timer? _retryTimer;
|
||||
String? _appVersion;
|
||||
bool _enabled = defaultEnabled;
|
||||
DateTime? _lastSuccessAt;
|
||||
String? _lastError;
|
||||
bool _isInitialized = false;
|
||||
bool _isFlushingQueue = false;
|
||||
|
||||
TrafficStatsReportingService({
|
||||
http.Client? client,
|
||||
DateTime Function()? now,
|
||||
Future<SharedPreferences> Function()? prefsProvider,
|
||||
Future<String> Function()? appVersionProvider,
|
||||
}) : _client = client ?? http.Client(),
|
||||
_ownsClient = client == null,
|
||||
_now = now ?? DateTime.now,
|
||||
_prefsProvider = prefsProvider ?? SharedPreferences.getInstance,
|
||||
_appVersionProvider = appVersionProvider ?? _defaultAppVersionProvider;
|
||||
|
||||
bool get isEnabled => _enabled;
|
||||
int get intervalMinutes => defaultIntervalMinutes;
|
||||
DateTime? get lastSuccessAt => _lastSuccessAt;
|
||||
String? get lastError => _lastError;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isFlushingQueue => _isFlushingQueue;
|
||||
int get pendingUploadCount => _queue.length;
|
||||
|
||||
Future<void> initialize({
|
||||
required String? Function() deviceKey6Provider,
|
||||
}) async {
|
||||
_deviceKey6Provider = deviceKey6Provider;
|
||||
final prefs = await _prefsProvider();
|
||||
_enabled = prefs.getBool(_enabledKey) ?? defaultEnabled;
|
||||
if (prefs.containsKey(_legacyIntervalKey)) {
|
||||
await prefs.remove(_legacyIntervalKey);
|
||||
}
|
||||
final queueJson = prefs.getString(_queueKey);
|
||||
if (queueJson != null && queueJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(queueJson);
|
||||
if (decoded is List) {
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(
|
||||
decoded.whereType<Map<String, dynamic>>().map(
|
||||
TrafficStatsQueuedReport.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
final lastSuccessAt = prefs.getString(_lastSuccessAtKey);
|
||||
if (lastSuccessAt != null && lastSuccessAt.isNotEmpty) {
|
||||
_lastSuccessAt = DateTime.tryParse(lastSuccessAt)?.toUtc();
|
||||
}
|
||||
final lastError = prefs.getString(_lastErrorKey);
|
||||
if (lastError != null && lastError.isNotEmpty) {
|
||||
_lastError = lastError;
|
||||
}
|
||||
try {
|
||||
_appVersion = await _appVersionProvider();
|
||||
} catch (_) {
|
||||
_appVersion = 'unknown';
|
||||
}
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer.periodic(_retryInterval, (_) {
|
||||
unawaited(flushPendingUploads());
|
||||
});
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
await flushPendingUploads();
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
if (_enabled == enabled) {
|
||||
return;
|
||||
}
|
||||
_enabled = enabled;
|
||||
_lastError = null;
|
||||
if (!enabled) {
|
||||
_openWindows.clear();
|
||||
}
|
||||
await _saveState();
|
||||
notifyListeners();
|
||||
if (enabled) {
|
||||
unawaited(flushPendingUploads());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> processLogs(List<BlePacketLog> logs) async {
|
||||
if (!_enabled || logs.isEmpty) {
|
||||
if (_enabled) {
|
||||
await flushPendingUploads();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
for (final log in logs) {
|
||||
if (!LiveTrafficSummary.isRxDataLog(log)) {
|
||||
continue;
|
||||
}
|
||||
final counts = _openWindows.putIfAbsent(
|
||||
_windowStartFor(log.timestamp).millisecondsSinceEpoch,
|
||||
TrafficStatsCounts.empty,
|
||||
);
|
||||
final route = LogRxRouteDecoder.decode(log.rawData);
|
||||
if (route == null) {
|
||||
counts.increment(TrafficStatsCounts.decodeFailKey);
|
||||
} else {
|
||||
counts.incrementPacketType(route.payloadType);
|
||||
}
|
||||
counts.increment(_pathModeKeyFor(log.rawData, route));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
await flushPendingUploads();
|
||||
return;
|
||||
}
|
||||
|
||||
final queuedReports = _queueClosedWindows();
|
||||
if (queuedReports > 0) {
|
||||
await _saveState();
|
||||
}
|
||||
notifyListeners();
|
||||
await flushPendingUploads();
|
||||
}
|
||||
|
||||
Future<void> flushPendingUploads() async {
|
||||
if (!_enabled || _queue.isEmpty || _isFlushingQueue) {
|
||||
return;
|
||||
}
|
||||
final deviceKey6 = _deviceKey6Provider?.call();
|
||||
if (deviceKey6 == null || deviceKey6.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isFlushingQueue = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
while (_queue.isNotEmpty && _enabled) {
|
||||
final report = _queue.first;
|
||||
final response = await _client.post(
|
||||
ingestUri,
|
||||
headers: const <String, String>{
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(report.toJson()),
|
||||
);
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
_lastError = 'Upload failed (${response.statusCode})';
|
||||
await _saveState();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_queue.removeAt(0);
|
||||
_lastError = null;
|
||||
_lastSuccessAt = _now().toUtc();
|
||||
await _saveState();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (error) {
|
||||
_lastError = 'Upload failed: $error';
|
||||
await _saveState();
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_isFlushingQueue = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
int _queueClosedWindows() {
|
||||
final deviceKey6 = _deviceKey6Provider?.call();
|
||||
if (deviceKey6 == null || deviceKey6.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final now = _now().toUtc();
|
||||
final closable = _openWindows.keys
|
||||
.where((windowStartMs) {
|
||||
final windowStart = DateTime.fromMillisecondsSinceEpoch(
|
||||
windowStartMs,
|
||||
isUtc: true,
|
||||
);
|
||||
final windowEnd = windowStart.add(
|
||||
Duration(minutes: defaultIntervalMinutes),
|
||||
);
|
||||
return !windowEnd.isAfter(now);
|
||||
})
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
for (final windowStartMs in closable) {
|
||||
final windowStart = DateTime.fromMillisecondsSinceEpoch(
|
||||
windowStartMs,
|
||||
isUtc: true,
|
||||
);
|
||||
final counts = _openWindows.remove(windowStartMs);
|
||||
if (counts == null || counts.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final reportId = '$deviceKey6:${windowStart.toIso8601String()}';
|
||||
final existingIndex = _queue.indexWhere(
|
||||
(report) => report.reportId == reportId,
|
||||
);
|
||||
if (existingIndex != -1) {
|
||||
_queue[existingIndex].counts.mergeFrom(counts);
|
||||
continue;
|
||||
}
|
||||
_queue.add(
|
||||
TrafficStatsQueuedReport(
|
||||
reportId: reportId,
|
||||
deviceKey6: deviceKey6,
|
||||
windowStart: windowStart,
|
||||
windowEnd: windowStart.add(
|
||||
Duration(minutes: defaultIntervalMinutes),
|
||||
),
|
||||
appVersion: _appVersion ?? 'unknown',
|
||||
counts: counts,
|
||||
),
|
||||
);
|
||||
}
|
||||
return closable.length;
|
||||
}
|
||||
|
||||
DateTime _windowStartFor(DateTime timestamp) {
|
||||
final utc = timestamp.toUtc();
|
||||
final alignedMinute =
|
||||
utc.minute - (utc.minute % defaultIntervalMinutes);
|
||||
return DateTime.utc(
|
||||
utc.year,
|
||||
utc.month,
|
||||
utc.day,
|
||||
utc.hour,
|
||||
alignedMinute,
|
||||
);
|
||||
}
|
||||
|
||||
String _pathModeKeyFor(Uint8List rawData, DecodedLogRxRoute? route) {
|
||||
if (route != null) {
|
||||
if (route.pathBytes.isEmpty) {
|
||||
return 'path_mode_none';
|
||||
}
|
||||
switch (route.hashSize) {
|
||||
case 1:
|
||||
return 'path_mode_1b';
|
||||
case 2:
|
||||
return 'path_mode_2b';
|
||||
case 3:
|
||||
return 'path_mode_3b';
|
||||
}
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
return _pathModeKeyFromRawData(rawData);
|
||||
}
|
||||
|
||||
String _pathModeKeyFromRawData(Uint8List rawData) {
|
||||
if (rawData.length < 5 ||
|
||||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
|
||||
final rawPacketData = rawData.sublist(3);
|
||||
if (rawPacketData.length < 2) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
|
||||
final header = rawPacketData[0];
|
||||
final routeType = header & 0x03;
|
||||
var index = 1;
|
||||
if (routeType == 0x00 || routeType == 0x03) {
|
||||
if (rawPacketData.length < index + 5) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
index += 4;
|
||||
}
|
||||
|
||||
if (rawPacketData.length <= index) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
|
||||
final pathDescriptor = rawPacketData[index];
|
||||
final pathByteLen = LogRxRouteDecoder.descriptorByteLength(pathDescriptor);
|
||||
if (pathByteLen == null) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
if (rawPacketData.length < index + 1 + pathByteLen) {
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
if (pathByteLen == 0) {
|
||||
return 'path_mode_none';
|
||||
}
|
||||
|
||||
final hashSize = LogRxRouteDecoder.descriptorHashSize(pathDescriptor);
|
||||
switch (hashSize) {
|
||||
case 1:
|
||||
return 'path_mode_1b';
|
||||
case 2:
|
||||
return 'path_mode_2b';
|
||||
case 3:
|
||||
return 'path_mode_3b';
|
||||
}
|
||||
return 'path_mode_unknown';
|
||||
}
|
||||
|
||||
Future<void> _saveState() async {
|
||||
final prefs = await _prefsProvider();
|
||||
await prefs.setBool(_enabledKey, _enabled);
|
||||
await prefs.remove(_legacyIntervalKey);
|
||||
await prefs.setString(
|
||||
_queueKey,
|
||||
jsonEncode(_queue.map((report) => report.toJson()).toList()),
|
||||
);
|
||||
if (_lastSuccessAt == null) {
|
||||
await prefs.remove(_lastSuccessAtKey);
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_lastSuccessAtKey,
|
||||
_lastSuccessAt!.toIso8601String(),
|
||||
);
|
||||
}
|
||||
if (_lastError == null || _lastError!.isEmpty) {
|
||||
await prefs.remove(_lastErrorKey);
|
||||
} else {
|
||||
await prefs.setString(_lastErrorKey, _lastError!);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _defaultAppVersionProvider() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
if (info.buildNumber.isEmpty || info.buildNumber == '0') {
|
||||
return info.version;
|
||||
}
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_retryTimer?.cancel();
|
||||
if (_ownsClient) {
|
||||
_client.close();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
226
lib/services/trail_color_service.dart
Normal file
226
lib/services/trail_color_service.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
/// Service for assigning consistent colors to contact trails
|
||||
/// Uses emoji-based semantic mapping with deterministic hash fallback
|
||||
class TrailColorService {
|
||||
// 64-color pastel palette optimized for visibility on all map types
|
||||
// Organized by hue families for better distribution
|
||||
// Avoids red/orange/yellow spectrum to prevent confusion with fire markers
|
||||
// Avoids pure blue (#2196F3) which is reserved for user trail
|
||||
static final List<Color> _colorPalette = [
|
||||
// Pinks & Light Corals (8)
|
||||
const Color(0xFFFFB6C1), // Light Pink
|
||||
const Color(0xFFFF7F7F), // Coral
|
||||
const Color(0xFFFFC0CB), // Pink
|
||||
const Color(0xFFFFB3BA), // Pastel Pink
|
||||
const Color(0xFFFF9AA2), // Light Coral
|
||||
const Color(0xFFFFDAE9), // Pale Pink
|
||||
const Color(0xFFFAA0B8), // Pastel Rose
|
||||
const Color(0xFFFF8FA3), // Salmon Pink
|
||||
|
||||
// Purples & Plums (8)
|
||||
const Color(0xFFE6E6FA), // Lavender
|
||||
const Color(0xFFDDA0DD), // Plum
|
||||
const Color(0xFFD8BFD8), // Thistle
|
||||
const Color(0xFFDDA5E9), // Pastel Purple
|
||||
const Color(0xFFE0BBE4), // Mauve
|
||||
const Color(0xFFC5A3E0), // Light Purple
|
||||
const Color(0xFFB19CD9), // Medium Lavender
|
||||
const Color(0xFFAF9FCD), // Wisteria
|
||||
|
||||
// Blues & Sky (12)
|
||||
const Color(0xFF87CEEB), // Sky Blue
|
||||
const Color(0xFFB0E0E6), // Powder Blue
|
||||
const Color(0xFFADD8E6), // Light Blue
|
||||
const Color(0xFF87CEFA), // Light Sky Blue
|
||||
const Color(0xFFB0C4DE), // Light Steel Blue
|
||||
const Color(0xFF9BB8D3), // Pastel Blue
|
||||
const Color(0xFF89CFF0), // Baby Blue
|
||||
const Color(0xFFA2C8EC), // Columbia Blue
|
||||
const Color(0xFF7FB3D5), // Pale Blue
|
||||
const Color(0xFF6A9FB5), // Air Force Blue
|
||||
const Color(0xFF8DB4D2), // Soft Blue
|
||||
const Color(0xFF7BA5C9), // Light Denim
|
||||
|
||||
// Cyans & Teals (8)
|
||||
const Color(0xFF5F9EA0), // Cadet Blue
|
||||
const Color(0xFF7FFFD4), // Aquamarine
|
||||
const Color(0xFF98D8C8), // Mint
|
||||
const Color(0xFF82E0D5), // Pale Cyan
|
||||
const Color(0xFF8FD8D8), // Light Teal
|
||||
const Color(0xFF81C0BB), // Cadet Teal
|
||||
const Color(0xFF72B0A8), // Medium Teal
|
||||
const Color(0xFF6FA09E), // Soft Teal
|
||||
|
||||
// Greens & Mints (8)
|
||||
const Color(0xFF90EE90), // Light Green
|
||||
const Color(0xFF98D8B4), // Celadon
|
||||
const Color(0xFFA8E4A0), // Granny Smith
|
||||
const Color(0xFFB2E8B2), // Tea Green
|
||||
const Color(0xFF9FD8AF), // Eton Blue
|
||||
const Color(0xFF8FC49F), // Pastel Green
|
||||
const Color(0xFF7EB693), // Cambridge Blue
|
||||
const Color(0xFF73A685), // Russian Green
|
||||
|
||||
// Beiges & Tans (12)
|
||||
const Color(0xFFD2B48C), // Tan
|
||||
const Color(0xFFDEB887), // Burlywood
|
||||
const Color(0xFFE0D8B0), // Beige
|
||||
const Color(0xFFFFDAB9), // Peach
|
||||
const Color(0xFFFFE4B5), // Moccasin
|
||||
const Color(0xFFFFF8DC), // Cornsilk
|
||||
const Color(0xFFE8D5C4), // Champagne
|
||||
const Color(0xFFD4C5B9), // Dust
|
||||
const Color(0xFFC9B8A9), // Khaki
|
||||
const Color(0xFFBCAA99), // Cashmere
|
||||
const Color(0xFFB09B87), // Taupe
|
||||
const Color(0xFFA58F7A), // Mocha
|
||||
|
||||
// Grays & Silvers (8)
|
||||
const Color(0xFFD3D3D3), // Light Gray
|
||||
const Color(0xFFC0C0C0), // Silver
|
||||
const Color(0xFFBCBCBC), // Bright Gray
|
||||
const Color(0xFFB2B2B2), // Medium Gray
|
||||
const Color(0xFFA9A9A9), // Dark Gray
|
||||
const Color(0xFF9E9E9E), // Gray
|
||||
const Color(0xFF8E8E8E), // Taupe Gray
|
||||
const Color(0xFF7E7E7E), // Granite
|
||||
];
|
||||
|
||||
// Emoji to color mapping for SAR roles
|
||||
// Uses pastel semantic colors for high visibility on maps
|
||||
// Avoids red/orange/yellow to prevent confusion with fire markers
|
||||
static final Map<String, Color> _emojiColorMap = {
|
||||
// Emergency Services - Firefighters
|
||||
'🚒': Color(0xFFFF7F7F), // Fire engine → Coral
|
||||
'🧑🚒': Color(0xFFFF7F7F), // Firefighter → Coral
|
||||
'👨🚒': Color(0xFFFF7F7F), // Firefighter → Coral
|
||||
'👩🚒': Color(0xFFFF7F7F), // Firefighter → Coral
|
||||
'🔥': Color(0xFFFFB6C1), // Fire → Light Pink
|
||||
|
||||
// Emergency Services - Medical
|
||||
'🚑': Color(0xFF7FFFD4), // Ambulance → Mint (medical cross)
|
||||
'👨⚕️': Color(0xFF7FFFD4), // Health worker → Mint
|
||||
'👩⚕️': Color(0xFF7FFFD4), // Health worker → Mint
|
||||
'🧑⚕️': Color(0xFF7FFFD4), // Health worker → Mint
|
||||
'⚕️': Color(0xFF7FFFD4), // Medical symbol → Mint
|
||||
|
||||
// Emergency Services - Police
|
||||
'👮': Color(0xFF87CEEB), // Police → Light Blue
|
||||
'👮♂️': Color(0xFF87CEEB), // Police → Light Blue
|
||||
'👮♀️': Color(0xFF87CEEB), // Police → Light Blue
|
||||
'🚔': Color(0xFF87CEEB), // Police car → Light Blue
|
||||
|
||||
// Emergency Services - Aviation
|
||||
'🧑✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
|
||||
'👨✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
|
||||
'👩✈️': Color(0xFFB0E0E6), // Pilot → Sky Blue
|
||||
'🚁': Color(0xFFE6E6FA), // Helicopter → Lavender
|
||||
|
||||
// SAR Roles - Mountain/Alpine
|
||||
'🏔️': Color(0xFFD2B48C), // Mountain → Tan
|
||||
'⛰️': Color(0xFFD2B48C), // Mountain → Tan
|
||||
'🧗': Color(0xFFD2B48C), // Climber → Tan
|
||||
'🧗♂️': Color(0xFFD2B48C), // Climber → Tan
|
||||
'🧗♀️': Color(0xFFD2B48C), // Climber → Tan
|
||||
'🥾': Color(0xFFDEB887), // Hiking boot → Burlywood
|
||||
|
||||
// SAR Roles - K9 Unit
|
||||
'🐕': Color(0xFFFFDAB9), // Dog → Peach
|
||||
'🐶': Color(0xFFFFDAB9), // Dog → Peach
|
||||
'🦮': Color(0xFFFFDAB9), // Service dog → Peach
|
||||
|
||||
// SAR Roles - Water Rescue
|
||||
'🚤': Color(0xFF87CEEB), // Speedboat → Sky Blue
|
||||
'⛵': Color(0xFF87CEEB), // Sailboat → Sky Blue
|
||||
'🏊': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
|
||||
'🏊♂️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
|
||||
'🏊♀️': Color(0xFF5F9EA0), // Swimmer → Cadet Blue
|
||||
|
||||
// Team Roles - Leadership
|
||||
'🎯': Color(0xFFFFB6C1), // Target → Light Pink (team leader)
|
||||
'⭐': Color(0xFFFFE4B5), // Star → Moccasin (coordinator)
|
||||
'👑': Color(0xFFFFE4B5), // Crown → Moccasin (leader)
|
||||
|
||||
// Team Roles - Communication
|
||||
'📡': Color(0xFF5F9EA0), // Satellite → Cadet Blue (radio/comms)
|
||||
'📻': Color(0xFF5F9EA0), // Radio → Cadet Blue
|
||||
'📞': Color(0xFF5F9EA0), // Phone → Cadet Blue
|
||||
|
||||
// Team Roles - Navigation
|
||||
'🗺️': Color(0xFF87CEEB), // Map → Sky Blue (navigator)
|
||||
'🧭': Color(0xFF87CEEB), // Compass → Sky Blue
|
||||
'📍': Color(0xFFFF7F7F), // Pin → Coral (location marker)
|
||||
|
||||
// Team Roles - Documentation
|
||||
'📷': Color(0xFFDDA0DD), // Camera → Plum
|
||||
'📹': Color(0xFFDDA0DD), // Video camera → Plum
|
||||
'📝': Color(0xFFE0E0A0), // Note → Khaki (scribe)
|
||||
|
||||
// Equipment
|
||||
'🔦': Color(0xFFFFE4B5), // Flashlight → Moccasin
|
||||
'⚡': Color(0xFFFFE4B5), // Lightning → Moccasin (power/energy)
|
||||
'🔋': Color(0xFF7FFFD4), // Battery → Mint
|
||||
'🎒': Color(0xFFDEB887), // Backpack → Burlywood
|
||||
|
||||
// Generic Person Icons
|
||||
'👤': Color(0xFFD3D3D3), // Silhouette → Light Gray
|
||||
'🧑': Color(0xFFD3D3D3), // Person → Light Gray
|
||||
'👨': Color(0xFFD3D3D3), // Man → Light Gray
|
||||
'👩': Color(0xFFD3D3D3), // Woman → Light Gray
|
||||
'👥': Color(0xFFC0C0C0), // People → Silver
|
||||
};
|
||||
|
||||
/// Get trail color for a contact
|
||||
/// Priority: Emoji mapping > Name hash > Default
|
||||
/// Returns fully opaque color - alpha transparency applied by caller
|
||||
static Color getTrailColor(Contact contact) {
|
||||
// 1. Try emoji-based color mapping
|
||||
if (contact.roleEmoji != null) {
|
||||
final emojiColor = _emojiColorMap[contact.roleEmoji];
|
||||
if (emojiColor != null) {
|
||||
return emojiColor;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Deterministic color based on display name
|
||||
// Use display name (without emoji) for consistent hashing
|
||||
final name = contact.displayName.isNotEmpty
|
||||
? contact.displayName
|
||||
: contact.publicKeyHex;
|
||||
|
||||
final hash = _hashString(name);
|
||||
final colorIndex = hash % _colorPalette.length; // 0-63
|
||||
|
||||
return _colorPalette[colorIndex];
|
||||
}
|
||||
|
||||
/// Simple string hash function (DJB2 algorithm)
|
||||
/// Same algorithm used for echo detection in the app
|
||||
static int _hashString(String str) {
|
||||
int hash = 5381;
|
||||
for (int i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.codeUnitAt(i);
|
||||
hash = hash & 0xFFFFFFFF; // Keep 32-bit
|
||||
}
|
||||
return hash.abs();
|
||||
}
|
||||
|
||||
/// Get all unique colors currently in use by contacts with trails
|
||||
static List<Color> getActiveColors(List<Contact> contacts) {
|
||||
final colors = <Color>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.advertHistory.length >= 2) {
|
||||
colors.add(getTrailColor(contact));
|
||||
}
|
||||
}
|
||||
return colors.toList();
|
||||
}
|
||||
|
||||
/// Check if a color is from emoji mapping (semantic) vs hash-based
|
||||
static bool isSemanticColor(Contact contact) {
|
||||
if (contact.roleEmoji == null) return false;
|
||||
return _emojiColorMap.containsKey(contact.roleEmoji);
|
||||
}
|
||||
}
|
||||
234
lib/services/update_checker_service.dart
Normal file
234
lib/services/update_checker_service.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/update_info.dart';
|
||||
import 'build_info_service.dart';
|
||||
|
||||
/// Service for checking if a new app version is available
|
||||
/// Compares current build's commit hash with latest GitHub release
|
||||
class UpdateCheckerService {
|
||||
static final UpdateCheckerService _instance =
|
||||
UpdateCheckerService._internal();
|
||||
factory UpdateCheckerService() => _instance;
|
||||
UpdateCheckerService._internal();
|
||||
|
||||
final BuildInfoService _buildInfoService = BuildInfoService();
|
||||
|
||||
static const String _repoOwner = 'dz0ny';
|
||||
static const String _repoName = 'meshcore-sar';
|
||||
static const String _latestReleaseUrl =
|
||||
'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest';
|
||||
|
||||
/// Check if an update is available
|
||||
/// Returns UpdateInfo with availability status and download URL if available
|
||||
Future<UpdateInfo> checkForUpdate() async {
|
||||
try {
|
||||
// Get current build's commit hash
|
||||
final currentCommitHash = await _buildInfoService.getCommitHash();
|
||||
|
||||
// Skip check for dev builds (local development)
|
||||
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') {
|
||||
debugPrint(
|
||||
'[UpdateChecker] Skipping update check for dev/unknown build',
|
||||
);
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
|
||||
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
|
||||
debugPrint(
|
||||
'[UpdateChecker] Fetching latest release from: $_latestReleaseUrl',
|
||||
);
|
||||
|
||||
// Fetch latest release from GitHub
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(_latestReleaseUrl),
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
debugPrint('[UpdateChecker] Latest release fetch timed out');
|
||||
throw Exception('Latest release fetch timed out');
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint(
|
||||
'[UpdateChecker] Failed to fetch release: ${response.statusCode}',
|
||||
);
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
|
||||
// Parse release JSON
|
||||
final Map<String, dynamic> release = json.decode(response.body);
|
||||
final tagName = release['tag_name'] as String?;
|
||||
final targetCommitish = release['target_commitish'] as String?;
|
||||
final publishedAt = release['published_at'] as String?;
|
||||
final assets = release['assets'] as List<dynamic>?;
|
||||
|
||||
if (tagName == null) {
|
||||
debugPrint('[UpdateChecker] Invalid release: missing tag_name');
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
|
||||
debugPrint('[UpdateChecker] Latest release tag: $tagName');
|
||||
if (targetCommitish != null && targetCommitish.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[UpdateChecker] Release target commitish: $targetCommitish',
|
||||
);
|
||||
}
|
||||
|
||||
final isUpdateAvailable = await _isUpdateAvailable(
|
||||
currentCommitHash: currentCommitHash,
|
||||
releaseTag: tagName,
|
||||
targetCommitish: targetCommitish,
|
||||
);
|
||||
|
||||
if (!isUpdateAvailable) {
|
||||
debugPrint('[UpdateChecker] No update available');
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
|
||||
// Find Android APK in release assets
|
||||
final String? apkUrl = _findAndroidApkUrl(assets);
|
||||
|
||||
if (apkUrl == null) {
|
||||
debugPrint(
|
||||
'[UpdateChecker] Update available but no APK found in artifacts',
|
||||
);
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
|
||||
debugPrint('[UpdateChecker] Update available! APK URL: $apkUrl');
|
||||
|
||||
return UpdateInfo.available(
|
||||
currentCommitHash: currentCommitHash,
|
||||
latestCommitHash: _formatLatestVersion(targetCommitish, tagName),
|
||||
downloadUrl: apkUrl,
|
||||
buildId: tagName,
|
||||
timestamp: publishedAt,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[UpdateChecker] Error checking for update: $e');
|
||||
// Return no update on error to avoid disrupting app startup
|
||||
final currentCommitHash = await _buildInfoService.getCommitHash();
|
||||
return UpdateInfo.noUpdate(currentCommitHash);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two commit hashes (handles both full SHA and short format)
|
||||
bool _compareCommitHashes(String current, String latest) {
|
||||
// Normalize to lowercase for comparison
|
||||
final currentLower = current.toLowerCase();
|
||||
final latestLower = latest.toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (currentLower == latestLower) return true;
|
||||
|
||||
// Check if current is short form of latest
|
||||
if (latestLower.startsWith(currentLower)) return true;
|
||||
|
||||
// Check if latest is short form of current
|
||||
if (currentLower.startsWith(latestLower)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _isUpdateAvailable({
|
||||
required String currentCommitHash,
|
||||
required String releaseTag,
|
||||
required String? targetCommitish,
|
||||
}) async {
|
||||
// Prefer direct SHA compare when release target is a hash.
|
||||
if (_looksLikeSha(targetCommitish)) {
|
||||
return !_compareCommitHashes(currentCommitHash, targetCommitish!);
|
||||
}
|
||||
|
||||
// Fallback: ask GitHub how current commit compares to the release tag.
|
||||
final compareResult = await _compareWithReleaseTag(
|
||||
currentCommitHash,
|
||||
releaseTag,
|
||||
);
|
||||
if (compareResult != null) {
|
||||
return compareResult;
|
||||
}
|
||||
|
||||
// If we cannot compare, do not force update prompts.
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool?> _compareWithReleaseTag(
|
||||
String currentCommitHash,
|
||||
String releaseTag,
|
||||
) async {
|
||||
try {
|
||||
final compareUrl =
|
||||
'https://api.github.com/repos/$_repoOwner/$_repoName/compare/$currentCommitHash...$releaseTag';
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(compareUrl),
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint(
|
||||
'[UpdateChecker] Compare API failed: ${response.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> comparison = json.decode(response.body);
|
||||
final status = comparison['status'] as String?;
|
||||
debugPrint('[UpdateChecker] Compare status: $status');
|
||||
|
||||
if (status == 'behind') return true;
|
||||
if (status == 'identical' || status == 'ahead') return false;
|
||||
|
||||
// "diverged" means the release and current commit differ.
|
||||
if (status == 'diverged') return true;
|
||||
} catch (e) {
|
||||
debugPrint('[UpdateChecker] Compare API error: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _looksLikeSha(String? value) {
|
||||
if (value == null) return false;
|
||||
final v = value.trim();
|
||||
if (v.length < 7 || v.length > 40) return false;
|
||||
return RegExp(r'^[a-fA-F0-9]+$').hasMatch(v);
|
||||
}
|
||||
|
||||
String _formatLatestVersion(String? targetCommitish, String tagName) {
|
||||
if (_looksLikeSha(targetCommitish)) {
|
||||
return targetCommitish!.substring(0, 7).toLowerCase();
|
||||
}
|
||||
return tagName;
|
||||
}
|
||||
|
||||
/// Find Android APK URL in release assets list
|
||||
String? _findAndroidApkUrl(List<dynamic>? assets) {
|
||||
if (assets == null || assets.isEmpty) return null;
|
||||
|
||||
for (final asset in assets) {
|
||||
if (asset is! Map<String, dynamic>) continue;
|
||||
final name = (asset['name'] as String?)?.toLowerCase() ?? '';
|
||||
if (!name.endsWith('.apk')) continue;
|
||||
|
||||
final browserDownloadUrl = asset['browser_download_url'] as String?;
|
||||
if (browserDownloadUrl != null && browserDownloadUrl.isNotEmpty) {
|
||||
return browserDownloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
511
lib/services/validation_service.dart
Normal file
511
lib/services/validation_service.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
/// Centralized validation service for form validation, coordinate validation,
|
||||
/// input sanitization, and common validation patterns used across the app.
|
||||
///
|
||||
/// This service provides structured validation results with helpful error messages
|
||||
/// and includes parse + validate methods for complex inputs.
|
||||
class ValidationService {
|
||||
// Singleton pattern
|
||||
static final ValidationService _instance = ValidationService._internal();
|
||||
factory ValidationService() => _instance;
|
||||
ValidationService._internal();
|
||||
|
||||
// ============================================================================
|
||||
// COORDINATE VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates latitude value (-90.0 to +90.0)
|
||||
ValidationResult validateLatitude(double? lat) {
|
||||
if (lat == null) {
|
||||
return const ValidationResult.invalid('Latitude is required');
|
||||
}
|
||||
if (lat < -90.0 || lat > 90.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Latitude must be between -90.0 and +90.0',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates longitude value (-180.0 to +180.0)
|
||||
ValidationResult validateLongitude(double? lon) {
|
||||
if (lon == null) {
|
||||
return const ValidationResult.invalid('Longitude is required');
|
||||
}
|
||||
if (lon < -180.0 || lon > 180.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Longitude must be between -180.0 and +180.0',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates both latitude and longitude coordinates
|
||||
ValidationResult validateCoordinates(double? lat, double? lon) {
|
||||
final latResult = validateLatitude(lat);
|
||||
if (!latResult.isValid) return latResult;
|
||||
|
||||
final lonResult = validateLongitude(lon);
|
||||
if (!lonResult.isValid) return lonResult;
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COORDINATE BOUNDS VALIDATION (for region downloads)
|
||||
// ============================================================================
|
||||
|
||||
/// Validates coordinate bounds for map region downloads
|
||||
///
|
||||
/// Checks:
|
||||
/// - All coordinates are valid numbers
|
||||
/// - North > South
|
||||
/// - East > West
|
||||
/// - Coordinates are within valid ranges
|
||||
ValidationResult validateBounds({
|
||||
required double? north,
|
||||
required double? south,
|
||||
required double? east,
|
||||
required double? west,
|
||||
}) {
|
||||
// Validate all coordinates exist
|
||||
if (north == null || south == null || east == null || west == null) {
|
||||
return const ValidationResult.invalid(
|
||||
'All coordinates are required (North, South, East, West)',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate individual coordinate ranges
|
||||
final northResult = validateLatitude(north);
|
||||
if (!northResult.isValid) {
|
||||
return ValidationResult.invalid('North: ${northResult.errorMessage}');
|
||||
}
|
||||
|
||||
final southResult = validateLatitude(south);
|
||||
if (!southResult.isValid) {
|
||||
return ValidationResult.invalid('South: ${southResult.errorMessage}');
|
||||
}
|
||||
|
||||
final eastResult = validateLongitude(east);
|
||||
if (!eastResult.isValid) {
|
||||
return ValidationResult.invalid('East: ${eastResult.errorMessage}');
|
||||
}
|
||||
|
||||
final westResult = validateLongitude(west);
|
||||
if (!westResult.isValid) {
|
||||
return ValidationResult.invalid('West: ${westResult.errorMessage}');
|
||||
}
|
||||
|
||||
// Validate bounds relationships
|
||||
if (north <= south) {
|
||||
return const ValidationResult.invalid(
|
||||
'North must be greater than South',
|
||||
);
|
||||
}
|
||||
|
||||
if (east <= west) {
|
||||
return const ValidationResult.invalid(
|
||||
'East must be greater than West',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RADIO PARAMETER VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates LoRa radio frequency in MHz (137.0 to 1020.0 MHz)
|
||||
ValidationResult validateFrequency(double? freqMhz) {
|
||||
if (freqMhz == null) {
|
||||
return const ValidationResult.invalid('Frequency is required');
|
||||
}
|
||||
if (freqMhz < 137.0 || freqMhz > 1020.0) {
|
||||
return const ValidationResult.invalid(
|
||||
'Frequency must be between 137.0 and 1020.0 MHz',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates TX power in dBm (-9 to +22 dBm typical, or up to maxPower)
|
||||
///
|
||||
/// If maxPower is provided, uses that as upper limit.
|
||||
/// Otherwise defaults to +22 dBm.
|
||||
ValidationResult validateTxPower(int? powerDbm, int? maxPower) {
|
||||
if (powerDbm == null) {
|
||||
return const ValidationResult.invalid('TX power is required');
|
||||
}
|
||||
|
||||
final max = maxPower ?? 22;
|
||||
|
||||
if (powerDbm < -9) {
|
||||
return const ValidationResult.invalid(
|
||||
'TX power must be at least -9 dBm',
|
||||
);
|
||||
}
|
||||
|
||||
if (powerDbm > max) {
|
||||
return ValidationResult.invalid(
|
||||
'TX power must not exceed $max dBm',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa bandwidth index (0-9)
|
||||
///
|
||||
/// Valid bandwidth indices:
|
||||
/// 0=7.8kHz, 1=10.4kHz, 2=15.6kHz, 3=20.8kHz, 4=31.25kHz,
|
||||
/// 5=41.7kHz, 6=62.5kHz, 7=125kHz, 8=250kHz, 9=500kHz
|
||||
ValidationResult validateBandwidth(int? bwIndex) {
|
||||
if (bwIndex == null) {
|
||||
return const ValidationResult.invalid('Bandwidth is required');
|
||||
}
|
||||
if (bwIndex < 0 || bwIndex > 9) {
|
||||
return const ValidationResult.invalid(
|
||||
'Bandwidth index must be between 0 and 9',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa spreading factor (7-12)
|
||||
ValidationResult validateSpreadingFactor(int? sf) {
|
||||
if (sf == null) {
|
||||
return const ValidationResult.invalid('Spreading factor is required');
|
||||
}
|
||||
if (sf < 7 || sf > 12) {
|
||||
return const ValidationResult.invalid(
|
||||
'Spreading factor must be between 7 and 12',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates LoRa coding rate (5-8)
|
||||
ValidationResult validateCodingRate(int? cr) {
|
||||
if (cr == null) {
|
||||
return const ValidationResult.invalid('Coding rate is required');
|
||||
}
|
||||
if (cr < 5 || cr > 8) {
|
||||
return const ValidationResult.invalid(
|
||||
'Coding rate must be between 5 and 8',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DISTANCE AND TIME VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates distance in meters
|
||||
///
|
||||
/// Optional min and max bounds can be provided.
|
||||
/// Defaults to 1m minimum if not specified.
|
||||
ValidationResult validateDistance(
|
||||
double? meters, {
|
||||
double? min,
|
||||
double? max,
|
||||
}) {
|
||||
if (meters == null) {
|
||||
return const ValidationResult.invalid('Distance is required');
|
||||
}
|
||||
|
||||
final minValue = min ?? 1.0;
|
||||
|
||||
if (meters < minValue) {
|
||||
return ValidationResult.invalid(
|
||||
'Distance must be at least ${minValue.toStringAsFixed(0)}m',
|
||||
);
|
||||
}
|
||||
|
||||
if (max != null && meters > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Distance must not exceed ${max.toStringAsFixed(0)}m',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates time interval in seconds
|
||||
///
|
||||
/// Optional min and max bounds can be provided.
|
||||
/// Defaults to 10 seconds minimum if not specified.
|
||||
ValidationResult validateTimeInterval(
|
||||
int? seconds, {
|
||||
int? min,
|
||||
int? max,
|
||||
}) {
|
||||
if (seconds == null) {
|
||||
return const ValidationResult.invalid('Time interval is required');
|
||||
}
|
||||
|
||||
final minValue = min ?? 10;
|
||||
|
||||
if (seconds < minValue) {
|
||||
return ValidationResult.invalid(
|
||||
'Time interval must be at least ${minValue}s',
|
||||
);
|
||||
}
|
||||
|
||||
if (max != null && seconds > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Time interval must not exceed ${max}s',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ZOOM LEVEL VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates map zoom level (1-19 for most tile sources)
|
||||
ValidationResult validateZoomLevel(int? zoom) {
|
||||
if (zoom == null) {
|
||||
return const ValidationResult.invalid('Zoom level is required');
|
||||
}
|
||||
if (zoom < 1 || zoom > 19) {
|
||||
return const ValidationResult.invalid(
|
||||
'Zoom level must be between 1 and 19',
|
||||
);
|
||||
}
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NAME AND TEXT VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Validates name/text field
|
||||
///
|
||||
/// Checks for:
|
||||
/// - Non-empty after trimming
|
||||
/// - Maximum length (defaults to 32 characters)
|
||||
ValidationResult validateName(String? name, {int? maxLength}) {
|
||||
if (name == null || name.trim().isEmpty) {
|
||||
return const ValidationResult.invalid('Name cannot be empty');
|
||||
}
|
||||
|
||||
final max = maxLength ?? 32;
|
||||
|
||||
if (name.length > max) {
|
||||
return ValidationResult.invalid(
|
||||
'Name must not exceed $max characters',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
/// Validates password field
|
||||
///
|
||||
/// Checks for:
|
||||
/// - Non-empty
|
||||
/// - Maximum length of 15 characters (MeshCore protocol limit)
|
||||
ValidationResult validatePassword(String? password) {
|
||||
if (password == null || password.isEmpty) {
|
||||
return const ValidationResult.invalid('Password cannot be empty');
|
||||
}
|
||||
|
||||
if (password.length > 15) {
|
||||
return const ValidationResult.invalid(
|
||||
'Password must not exceed 15 characters',
|
||||
);
|
||||
}
|
||||
|
||||
return const ValidationResult.valid();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARSE AND VALIDATE METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Parses and validates latitude string
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseLatitude(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Latitude is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateLatitude(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates longitude string
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseLongitude(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Longitude is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateLongitude(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates frequency string (in MHz)
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<double> parseFrequency(String text) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('Frequency is required');
|
||||
}
|
||||
|
||||
final value = double.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateFrequency(value);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
/// Parses and validates TX power string (in dBm)
|
||||
///
|
||||
/// Returns ParseResult with parsed value or error message.
|
||||
ParseResult<int> parseTxPower(String text, {int? maxPower}) {
|
||||
if (text.trim().isEmpty) {
|
||||
return const ParseResult.error('TX power is required');
|
||||
}
|
||||
|
||||
final value = int.tryParse(text.trim());
|
||||
if (value == null) {
|
||||
return const ParseResult.error('Invalid number format');
|
||||
}
|
||||
|
||||
final validation = validateTxPower(value, maxPower);
|
||||
if (!validation.isValid) {
|
||||
return ParseResult.error(validation.errorMessage!);
|
||||
}
|
||||
|
||||
return ParseResult.success(value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SANITIZATION METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Sanitizes name string
|
||||
///
|
||||
/// - Trims whitespace
|
||||
/// - Removes control characters
|
||||
/// - Truncates to maxLength if specified (defaults to 32)
|
||||
String sanitizeName(String name, {int? maxLength}) {
|
||||
final max = maxLength ?? 32;
|
||||
|
||||
// Trim whitespace
|
||||
String sanitized = name.trim();
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.length > max) {
|
||||
sanitized = sanitized.substring(0, max);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// Sanitizes password string
|
||||
///
|
||||
/// - Removes whitespace
|
||||
/// - Removes control characters
|
||||
/// - Truncates to 15 characters (MeshCore protocol limit)
|
||||
String sanitizePassword(String password) {
|
||||
// Remove all whitespace
|
||||
String sanitized = password.replaceAll(RegExp(r'\s'), '');
|
||||
|
||||
// Remove control characters
|
||||
sanitized = sanitized.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Truncate to protocol limit
|
||||
if (sanitized.length > 15) {
|
||||
sanitized = sanitized.substring(0, 15);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// RESULT CLASSES
|
||||
// ==============================================================================
|
||||
|
||||
/// Result of a validation operation
|
||||
///
|
||||
/// Contains either success (isValid=true) or failure with error message.
|
||||
class ValidationResult {
|
||||
/// Whether the validation passed
|
||||
final bool isValid;
|
||||
|
||||
/// Error message if validation failed (null if valid)
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a valid result
|
||||
const ValidationResult.valid()
|
||||
: isValid = true,
|
||||
errorMessage = null;
|
||||
|
||||
/// Creates an invalid result with error message
|
||||
const ValidationResult.invalid(this.errorMessage) : isValid = false;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return isValid ? 'Valid' : 'Invalid: $errorMessage';
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a parse operation
|
||||
///
|
||||
/// Contains either parsed value (success) or error message (failure).
|
||||
class ParseResult<T> {
|
||||
/// Parsed value if successful (null if error)
|
||||
final T? value;
|
||||
|
||||
/// Error message if parsing failed (null if successful)
|
||||
final String? errorMessage;
|
||||
|
||||
/// Creates a successful parse result
|
||||
const ParseResult.success(this.value) : errorMessage = null;
|
||||
|
||||
/// Creates a failed parse result with error message
|
||||
const ParseResult.error(this.errorMessage) : value = null;
|
||||
|
||||
/// Whether the parse operation succeeded
|
||||
bool get isSuccess => value != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return isSuccess ? 'Success: $value' : 'Error: $errorMessage';
|
||||
}
|
||||
}
|
||||
52
lib/services/voice_bitrate_preferences.dart
Normal file
52
lib/services/voice_bitrate_preferences.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
/// Stores user-selected voice bitrate and maps it to supported codec modes.
|
||||
class VoiceBitratePreferences {
|
||||
static const String _bitrateKey = 'voice_bitrate';
|
||||
static const int defaultBitrate = 1300;
|
||||
static const List<int> supportedBitrates = [
|
||||
700,
|
||||
1200,
|
||||
1300,
|
||||
1400,
|
||||
1600,
|
||||
2400,
|
||||
3200,
|
||||
];
|
||||
|
||||
static Future<int> getBitrate() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_bitrateKey)) ??
|
||||
defaultBitrate;
|
||||
return supportedBitrates.contains(value) ? value : defaultBitrate;
|
||||
}
|
||||
|
||||
static Future<void> setBitrate(int bitrate) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(ProfileStorageScope.scopedKey(_bitrateKey), bitrate);
|
||||
}
|
||||
|
||||
static VoicePacketMode toVoiceMode(int bitrate) {
|
||||
switch (bitrate) {
|
||||
case 1200:
|
||||
return VoicePacketMode.mode1200;
|
||||
case 1300:
|
||||
return VoicePacketMode.mode1300;
|
||||
case 1400:
|
||||
return VoicePacketMode.mode1400;
|
||||
case 1600:
|
||||
return VoicePacketMode.mode1600;
|
||||
case 2400:
|
||||
return VoicePacketMode.mode2400;
|
||||
case 3200:
|
||||
return VoicePacketMode.mode3200;
|
||||
case 700:
|
||||
return VoicePacketMode.mode700c;
|
||||
default:
|
||||
return VoicePacketMode.mode1300;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
lib/services/voice_codec_service.dart
Normal file
2
lib/services/voice_codec_service.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'voice_codec_service_stub.dart'
|
||||
if (dart.library.io) 'voice_codec_service_io.dart';
|
||||
80
lib/services/voice_codec_service_io.dart
Normal file
80
lib/services/voice_codec_service_io.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:codec2_flutter/codec2_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||
switch (pktMode) {
|
||||
case VoicePacketMode.mode3200:
|
||||
return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600:
|
||||
return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400:
|
||||
return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c:
|
||||
return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200:
|
||||
return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300:
|
||||
return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400:
|
||||
return Codec2Mode.mode2400;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth.
|
||||
///
|
||||
/// Call with [radioBandwidthHz] from the device's radio params
|
||||
/// (e.g. 125000 for 125 kHz).
|
||||
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
||||
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
||||
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
||||
return VoicePacketMode.mode1300;
|
||||
}
|
||||
|
||||
/// High-level codec service that provides async Codec2 encode/decode
|
||||
/// executed in a background isolate so the UI thread is never blocked.
|
||||
class VoiceCodecService {
|
||||
void _ensureCodec2Supported() {
|
||||
if (kIsWeb ||
|
||||
(defaultTargetPlatform != TargetPlatform.iOS &&
|
||||
defaultTargetPlatform != TargetPlatform.android)) {
|
||||
throw UnsupportedError('Codec2 is enabled only on iOS and Android.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||
_ensureCodec2Supported();
|
||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||
}
|
||||
|
||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||
_ensureCodec2Supported();
|
||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||
}
|
||||
|
||||
/// Decode and concatenate multiple [packets] into a single PCM Int16List.
|
||||
/// Packets with null/missing entries are substituted with silence.
|
||||
Future<Int16List> decodePackets(
|
||||
List<VoicePacket?> packets,
|
||||
VoicePacketMode mode,
|
||||
) async {
|
||||
_ensureCodec2Supported();
|
||||
final all = <Int16List>[];
|
||||
for (final pkt in packets) {
|
||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||
all.add(Int16List(mode.samplesPerPacket));
|
||||
} else {
|
||||
all.add(await decode(pkt.codec2Data, mode));
|
||||
}
|
||||
}
|
||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||
final result = Int16List(total);
|
||||
var offset = 0;
|
||||
for (final chunk in all) {
|
||||
result.setRange(offset, offset + chunk.length, chunk);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
83
lib/services/voice_codec_service_stub.dart
Normal file
83
lib/services/voice_codec_service_stub.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
enum Codec2Mode {
|
||||
mode3200(0),
|
||||
mode2400(1),
|
||||
mode1600(2),
|
||||
mode1400(3),
|
||||
mode1300(4),
|
||||
mode1200(5),
|
||||
mode700c(8);
|
||||
|
||||
const Codec2Mode(this.c2ModeId);
|
||||
final int c2ModeId;
|
||||
|
||||
int get framesPerSecond => (this == mode3200 || this == mode2400) ? 50 : 25;
|
||||
int get samplesPerFrame => 8000 ~/ framesPerSecond;
|
||||
|
||||
int get bytesPerSecond {
|
||||
switch (this) {
|
||||
case mode3200:
|
||||
return 400;
|
||||
case mode700c:
|
||||
return 100;
|
||||
case mode1200:
|
||||
return 150;
|
||||
case mode1300:
|
||||
return 175;
|
||||
case mode1400:
|
||||
return 175;
|
||||
case mode1600:
|
||||
return 200;
|
||||
case mode2400:
|
||||
return 300;
|
||||
}
|
||||
}
|
||||
|
||||
static const int _maxBytesPerPacket = 160;
|
||||
|
||||
int get packetDurationMs {
|
||||
final bytesPerFrame = bytesPerSecond / framesPerSecond;
|
||||
final framesPerPacket = (_maxBytesPerPacket / bytesPerFrame).floor();
|
||||
return (framesPerPacket * 1000 ~/ framesPerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||
switch (pktMode) {
|
||||
case VoicePacketMode.mode3200:
|
||||
return Codec2Mode.mode3200;
|
||||
case VoicePacketMode.mode1600:
|
||||
return Codec2Mode.mode1600;
|
||||
case VoicePacketMode.mode1400:
|
||||
return Codec2Mode.mode1400;
|
||||
case VoicePacketMode.mode700c:
|
||||
return Codec2Mode.mode700c;
|
||||
case VoicePacketMode.mode1200:
|
||||
return Codec2Mode.mode1200;
|
||||
case VoicePacketMode.mode1300:
|
||||
return Codec2Mode.mode1300;
|
||||
case VoicePacketMode.mode2400:
|
||||
return Codec2Mode.mode2400;
|
||||
}
|
||||
}
|
||||
|
||||
VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) {
|
||||
if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c;
|
||||
if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200;
|
||||
return VoicePacketMode.mode1300;
|
||||
}
|
||||
|
||||
class VoiceCodecService {
|
||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) =>
|
||||
Future.error(UnsupportedError('Voice not supported on web'));
|
||||
|
||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) =>
|
||||
Future.error(UnsupportedError('Voice not supported on web'));
|
||||
|
||||
Future<Int16List> decodePackets(
|
||||
List<VoicePacket?> packets,
|
||||
VoicePacketMode mode,
|
||||
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||
}
|
||||
182
lib/services/voice_player_service.dart
Normal file
182
lib/services/voice_player_service.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:async';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Plays decoded mono PCM samples by writing a WAV file
|
||||
/// to the system temp directory and using [AudioPlayer].
|
||||
class VoicePlayerService {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
final StreamController<void> _events = StreamController<void>.broadcast();
|
||||
bool _isPlaying = false;
|
||||
bool _isDisposed = false;
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
Timer? _fallbackTicker;
|
||||
DateTime? _playbackStartedAt;
|
||||
|
||||
bool get isPlaying => _isPlaying;
|
||||
Duration get position => _position;
|
||||
Duration get duration => _duration;
|
||||
Stream<void> get events => _events.stream;
|
||||
|
||||
VoicePlayerService() {
|
||||
_player.onPlayerStateChanged.listen((state) {
|
||||
debugPrint('🔊 [VoicePlayer] state → $state');
|
||||
_isPlaying = state == PlayerState.playing;
|
||||
if (_isPlaying) {
|
||||
_startFallbackTicker();
|
||||
} else {
|
||||
_stopFallbackTicker();
|
||||
}
|
||||
_emit();
|
||||
});
|
||||
_player.onLog.listen((msg) => debugPrint('🔊 [VoicePlayer] log: $msg'));
|
||||
_player.onPositionChanged.listen((position) {
|
||||
_position = position;
|
||||
_emit();
|
||||
});
|
||||
_player.onDurationChanged.listen((duration) {
|
||||
_duration = duration;
|
||||
_emit();
|
||||
});
|
||||
_player.onPlayerComplete.listen((_) {
|
||||
_isPlaying = false;
|
||||
_position = _duration;
|
||||
_stopFallbackTicker();
|
||||
_emit();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||
if (_isPlaying) await stop();
|
||||
_position = Duration.zero;
|
||||
_duration = Duration(
|
||||
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||
);
|
||||
_playbackStartedAt = DateTime.now();
|
||||
_emit();
|
||||
|
||||
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||
await file.writeAsBytes(wavBytes);
|
||||
debugPrint(
|
||||
'🔊 [VoicePlayer] WAV written: ${wavBytes.length} bytes → ${file.path}',
|
||||
);
|
||||
|
||||
try {
|
||||
_isPlaying = true;
|
||||
_startFallbackTicker();
|
||||
_emit();
|
||||
await _player.play(DeviceFileSource(file.path));
|
||||
debugPrint('🔊 [VoicePlayer] play() returned (audio playing)');
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoicePlayer] play() error: $e\n$st');
|
||||
_isPlaying = false;
|
||||
_stopFallbackTicker();
|
||||
_emit();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
debugPrint('🔊 [VoicePlayer] stop()');
|
||||
await _player.stop();
|
||||
_isPlaying = false;
|
||||
_position = Duration.zero;
|
||||
_playbackStartedAt = null;
|
||||
_stopFallbackTicker();
|
||||
_emit();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_stopFallbackTicker();
|
||||
_events.close();
|
||||
_player.dispose();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_isDisposed) {
|
||||
_events.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _startFallbackTicker() {
|
||||
if (_fallbackTicker != null) return;
|
||||
_fallbackTicker = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
if (!_isPlaying || _duration.inMilliseconds <= 0) return;
|
||||
final startedAt = _playbackStartedAt;
|
||||
if (startedAt == null) return;
|
||||
final elapsed = DateTime.now().difference(startedAt);
|
||||
final clamped = elapsed > _duration ? _duration : elapsed;
|
||||
if (clamped > _position) {
|
||||
_position = clamped;
|
||||
_emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopFallbackTicker() {
|
||||
_fallbackTicker?.cancel();
|
||||
_fallbackTicker = null;
|
||||
}
|
||||
|
||||
// ── WAV file builder ─────────────────────────────────────────────────────
|
||||
|
||||
/// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples.
|
||||
static Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
||||
const int numChannels = 1;
|
||||
const int bitsPerSample = 16;
|
||||
const int audioFormat = 1; // PCM
|
||||
|
||||
final dataSize = samples.length * 2; // 2 bytes per Int16 sample
|
||||
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
||||
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
||||
final totalSize = 36 + dataSize;
|
||||
|
||||
final buf = ByteData(44 + dataSize);
|
||||
var offset = 0;
|
||||
|
||||
void writeStr(String s) {
|
||||
for (final c in s.codeUnits) {
|
||||
buf.setUint8(offset++, c);
|
||||
}
|
||||
}
|
||||
|
||||
void writeU32(int v) {
|
||||
buf.setUint32(offset, v, Endian.little);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
void writeU16(int v) {
|
||||
buf.setUint16(offset, v, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
writeStr('RIFF');
|
||||
writeU32(totalSize);
|
||||
writeStr('WAVE');
|
||||
writeStr('fmt ');
|
||||
writeU32(16); // subchunk1 size
|
||||
writeU16(audioFormat); // 1 = PCM
|
||||
writeU16(numChannels);
|
||||
writeU32(sampleRate);
|
||||
writeU32(byteRate);
|
||||
writeU16(blockAlign);
|
||||
writeU16(bitsPerSample);
|
||||
writeStr('data');
|
||||
writeU32(dataSize);
|
||||
|
||||
// PCM sample data (little-endian Int16)
|
||||
for (final s in samples) {
|
||||
buf.setInt16(offset, s, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buf.buffer.asUint8List();
|
||||
}
|
||||
}
|
||||
396
lib/services/voice_recorder_service.dart
Normal file
396
lib/services/voice_recorder_service.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||
///
|
||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||
class VoiceRecorderService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
StreamSubscription<Uint8List>? _sub;
|
||||
StreamController<Int16List>? _controller;
|
||||
|
||||
bool _isRecording = false;
|
||||
bool get isRecording => _isRecording;
|
||||
|
||||
/// Request microphone permission. Returns true if granted.
|
||||
Future<bool> requestPermission() async {
|
||||
return _recorder.hasPermission(request: true);
|
||||
}
|
||||
|
||||
/// Start capturing PCM audio.
|
||||
///
|
||||
/// [chunkDuration] controls how often samples are emitted (default 1 s).
|
||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||
Stream<Int16List> startCapture({
|
||||
Duration chunkDuration = const Duration(seconds: 1),
|
||||
int sampleRateHz = 8000,
|
||||
bool enableBandPassFilter = true,
|
||||
bool enableCompressor = true,
|
||||
bool enableLimiter = true,
|
||||
bool enableAutoGain = false,
|
||||
bool enableEchoCancellation = false,
|
||||
bool enableNoiseSuppression = false,
|
||||
}) {
|
||||
if (_isRecording) {
|
||||
throw StateError('VoiceRecorderService: already recording');
|
||||
}
|
||||
|
||||
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
|
||||
_isRecording = true;
|
||||
|
||||
_startRecording(
|
||||
chunkDuration,
|
||||
sampleRateHz: sampleRateHz,
|
||||
enableBandPassFilter: enableBandPassFilter,
|
||||
enableCompressor: enableCompressor,
|
||||
enableLimiter: enableLimiter,
|
||||
enableAutoGain: enableAutoGain,
|
||||
enableEchoCancellation: enableEchoCancellation,
|
||||
enableNoiseSuppression: enableNoiseSuppression,
|
||||
);
|
||||
return _controller!.stream;
|
||||
}
|
||||
|
||||
Future<void> _startRecording(
|
||||
Duration chunkDuration, {
|
||||
required int sampleRateHz,
|
||||
required bool enableBandPassFilter,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
required bool enableAutoGain,
|
||||
required bool enableEchoCancellation,
|
||||
required bool enableNoiseSuppression,
|
||||
}) async {
|
||||
final useBandPassFilter = enableBandPassFilter;
|
||||
final useCompressor = enableCompressor;
|
||||
final useLimiter = enableLimiter;
|
||||
final config = RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
sampleRate: sampleRateHz,
|
||||
numChannels: 1,
|
||||
bitRate: 128000, // ignored for PCM, but required by API
|
||||
autoGain: enableAutoGain,
|
||||
echoCancel: enableEchoCancellation,
|
||||
noiseSuppress: enableNoiseSuppression,
|
||||
);
|
||||
|
||||
try {
|
||||
final stream = await _recorder.startStream(config);
|
||||
final voiceFilter = _VoiceBandPassFilter(
|
||||
sampleRate: sampleRateHz,
|
||||
lowCutHz: 250.0,
|
||||
highCutHz: 3400.0,
|
||||
);
|
||||
final dynamics = _VoiceDynamicsProcessor(
|
||||
sampleRate: sampleRateHz,
|
||||
thresholdDb: -18.0,
|
||||
ratio: 2.5,
|
||||
attackMs: 8.0,
|
||||
releaseMs: 120.0,
|
||||
makeupGainDb: 4.0,
|
||||
enableCompressor: useCompressor,
|
||||
enableLimiter: useLimiter,
|
||||
);
|
||||
final chunkBytes =
|
||||
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||
final buffer = <int>[];
|
||||
|
||||
_sub = stream.listen(
|
||||
(data) {
|
||||
buffer.addAll(data);
|
||||
while (buffer.length >= chunkBytes) {
|
||||
final chunk = buffer.sublist(0, chunkBytes);
|
||||
buffer.removeRange(0, chunkBytes);
|
||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||
_controller?.add(dynamics.process(filtered));
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (buffer.isNotEmpty) {
|
||||
final padded = _padToEven(buffer);
|
||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||
_controller?.add(dynamics.process(filtered));
|
||||
}
|
||||
_controller?.close();
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('❌ [VoiceRecorder] Stream error: $e');
|
||||
_controller?.addError(e);
|
||||
_controller?.close();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceRecorder] Failed to start: $e');
|
||||
_isRecording = false;
|
||||
_controller?.addError(e);
|
||||
_controller?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording and flush remaining samples.
|
||||
Future<void> stopCapture() async {
|
||||
await _stopInternal();
|
||||
}
|
||||
|
||||
Future<void> _stopInternal() async {
|
||||
if (!_isRecording) return;
|
||||
_isRecording = false;
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
await _recorder.stop();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_stopInternal();
|
||||
_recorder.dispose();
|
||||
}
|
||||
|
||||
/// Convert raw little-endian PCM bytes to Int16List.
|
||||
static Int16List _bytesToInt16(Uint8List bytes) {
|
||||
final bd = ByteData.sublistView(bytes);
|
||||
final samples = Int16List(bytes.length ~/ 2);
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
samples[i] = bd.getInt16(i * 2, Endian.little);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
static List<int> _padToEven(List<int> buf) {
|
||||
if (buf.length % 2 != 0) buf.add(0);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
/// Light speech-focused dynamics processing.
|
||||
///
|
||||
/// Compressor improves low-level intelligibility; limiter prevents peaks that
|
||||
/// can create harsh codec artifacts.
|
||||
class _VoiceDynamicsProcessor {
|
||||
final bool _enableCompressor;
|
||||
final bool _enableLimiter;
|
||||
final _SimpleCompressor _compressor;
|
||||
final _PeakLimiter _limiter;
|
||||
|
||||
_VoiceDynamicsProcessor({
|
||||
required int sampleRate,
|
||||
required double thresholdDb,
|
||||
required double ratio,
|
||||
required double attackMs,
|
||||
required double releaseMs,
|
||||
required double makeupGainDb,
|
||||
required bool enableCompressor,
|
||||
required bool enableLimiter,
|
||||
}) : _enableCompressor = enableCompressor,
|
||||
_enableLimiter = enableLimiter,
|
||||
_compressor = _SimpleCompressor(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
thresholdDb: thresholdDb,
|
||||
ratio: ratio,
|
||||
attackMs: attackMs,
|
||||
releaseMs: releaseMs,
|
||||
makeupGainDb: makeupGainDb,
|
||||
),
|
||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||
|
||||
Int16List process(Int16List input) {
|
||||
final output = Int16List(input.length);
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var sample = input[i].toDouble();
|
||||
if (_enableCompressor) {
|
||||
sample = _compressor.process(sample);
|
||||
}
|
||||
if (_enableLimiter) {
|
||||
sample = _limiter.process(sample);
|
||||
}
|
||||
output[i] = sample.clamp(-32768.0, 32767.0).round();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic feed-forward compressor with attack/release smoothing.
|
||||
class _SimpleCompressor {
|
||||
final double _thresholdDb;
|
||||
final double _ratio;
|
||||
final double _makeupGain;
|
||||
final double _attackCoeff;
|
||||
final double _releaseCoeff;
|
||||
static const double _eps = 1.0;
|
||||
|
||||
double _env = 0.0;
|
||||
double _gain = 1.0;
|
||||
|
||||
_SimpleCompressor({
|
||||
required double sampleRate,
|
||||
required double thresholdDb,
|
||||
required double ratio,
|
||||
required double attackMs,
|
||||
required double releaseMs,
|
||||
required double makeupGainDb,
|
||||
}) : _thresholdDb = thresholdDb,
|
||||
_ratio = ratio,
|
||||
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
|
||||
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
|
||||
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
|
||||
|
||||
double process(double x) {
|
||||
final absX = x.abs();
|
||||
final envCoeff = absX > _env ? _attackCoeff : _releaseCoeff;
|
||||
_env = envCoeff * _env + (1.0 - envCoeff) * absX;
|
||||
|
||||
final envDb = 20.0 * math.log((_env + _eps) / 32768.0) / math.ln10;
|
||||
var targetGain = 1.0;
|
||||
if (envDb > _thresholdDb) {
|
||||
final outDb = _thresholdDb + (envDb - _thresholdDb) / _ratio;
|
||||
final gainDb = outDb - envDb;
|
||||
targetGain = math.pow(10.0, gainDb / 20.0).toDouble();
|
||||
}
|
||||
targetGain *= _makeupGain;
|
||||
|
||||
final gainCoeff = targetGain < _gain ? _attackCoeff : _releaseCoeff;
|
||||
_gain = gainCoeff * _gain + (1.0 - gainCoeff) * targetGain;
|
||||
return x * _gain;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard peak limiter with fixed ceiling.
|
||||
class _PeakLimiter {
|
||||
final double _ceiling;
|
||||
|
||||
_PeakLimiter({required double ceilingDb})
|
||||
: _ceiling = 32767.0 * math.pow(10.0, ceilingDb / 20.0).toDouble();
|
||||
|
||||
double process(double x) {
|
||||
if (x > _ceiling) return _ceiling;
|
||||
if (x < -_ceiling) return -_ceiling;
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
/// Band-pass filter tuned for human voice at 8 kHz input.
|
||||
///
|
||||
/// Uses a cascaded high-pass + low-pass biquad to attenuate very low-frequency
|
||||
/// rumble and high-frequency noise outside the speech band.
|
||||
class _VoiceBandPassFilter {
|
||||
final _BiquadFilter _highPass;
|
||||
final _BiquadFilter _lowPass;
|
||||
|
||||
_VoiceBandPassFilter({
|
||||
required int sampleRate,
|
||||
required double lowCutHz,
|
||||
required double highCutHz,
|
||||
}) : _highPass = _BiquadFilter.highPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: lowCutHz,
|
||||
),
|
||||
_lowPass = _BiquadFilter.lowPass(
|
||||
sampleRate: sampleRate.toDouble(),
|
||||
cutoffHz: highCutHz,
|
||||
);
|
||||
|
||||
Int16List process(Int16List input) {
|
||||
final output = Int16List(input.length);
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
var sample = input[i].toDouble();
|
||||
sample = _highPass.process(sample);
|
||||
sample = _lowPass.process(sample);
|
||||
output[i] = sample.clamp(-32768.0, 32767.0).round();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard biquad IIR filter (Direct Form I).
|
||||
class _BiquadFilter {
|
||||
final double _b0;
|
||||
final double _b1;
|
||||
final double _b2;
|
||||
final double _a1;
|
||||
final double _a2;
|
||||
|
||||
double _x1 = 0.0;
|
||||
double _x2 = 0.0;
|
||||
double _y1 = 0.0;
|
||||
double _y2 = 0.0;
|
||||
|
||||
_BiquadFilter._({
|
||||
required double b0,
|
||||
required double b1,
|
||||
required double b2,
|
||||
required double a1,
|
||||
required double a2,
|
||||
}) : _b0 = b0,
|
||||
_b1 = b1,
|
||||
_b2 = b2,
|
||||
_a1 = a1,
|
||||
_a2 = a2;
|
||||
|
||||
factory _BiquadFilter.lowPass({
|
||||
required double sampleRate,
|
||||
required double cutoffHz,
|
||||
}) {
|
||||
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
|
||||
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
|
||||
final cosOmega = math.cos(omega);
|
||||
final alpha = math.sin(omega) / (2.0 * q);
|
||||
|
||||
final b0 = (1.0 - cosOmega) / 2.0;
|
||||
final b1 = 1.0 - cosOmega;
|
||||
final b2 = (1.0 - cosOmega) / 2.0;
|
||||
final a0 = 1.0 + alpha;
|
||||
final a1 = -2.0 * cosOmega;
|
||||
final a2 = 1.0 - alpha;
|
||||
|
||||
return _BiquadFilter._(
|
||||
b0: b0 / a0,
|
||||
b1: b1 / a0,
|
||||
b2: b2 / a0,
|
||||
a1: a1 / a0,
|
||||
a2: a2 / a0,
|
||||
);
|
||||
}
|
||||
|
||||
factory _BiquadFilter.highPass({
|
||||
required double sampleRate,
|
||||
required double cutoffHz,
|
||||
}) {
|
||||
const q = math.sqrt1_2; // Butterworth response (Q = 1/sqrt(2))
|
||||
final omega = 2.0 * math.pi * cutoffHz / sampleRate;
|
||||
final cosOmega = math.cos(omega);
|
||||
final alpha = math.sin(omega) / (2.0 * q);
|
||||
|
||||
final b0 = (1.0 + cosOmega) / 2.0;
|
||||
final b1 = -(1.0 + cosOmega);
|
||||
final b2 = (1.0 + cosOmega) / 2.0;
|
||||
final a0 = 1.0 + alpha;
|
||||
final a1 = -2.0 * cosOmega;
|
||||
final a2 = 1.0 - alpha;
|
||||
|
||||
return _BiquadFilter._(
|
||||
b0: b0 / a0,
|
||||
b1: b1 / a0,
|
||||
b2: b2 / a0,
|
||||
a1: a1 / a0,
|
||||
a2: a2 / a0,
|
||||
);
|
||||
}
|
||||
|
||||
double process(double x) {
|
||||
final y = _b0 * x + _b1 * _x1 + _b2 * _x2 - _a1 * _y1 - _a2 * _y2;
|
||||
_x2 = _x1;
|
||||
_x1 = x;
|
||||
_y2 = _y1;
|
||||
_y1 = y;
|
||||
return y;
|
||||
}
|
||||
}
|
||||
42
lib/services/wizard_preferences.dart
Normal file
42
lib/services/wizard_preferences.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Service to manage welcome wizard preferences and state
|
||||
class WizardPreferences {
|
||||
static const String _wizardCompletedKey = 'wizard_completed';
|
||||
static const String _wizardVersionKey = 'wizard_version';
|
||||
static const int _currentWizardVersion = 1;
|
||||
|
||||
/// Check if the welcome wizard has been completed
|
||||
static Future<bool> isWizardCompleted() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final completed = prefs.getBool(_wizardCompletedKey) ?? false;
|
||||
final version = prefs.getInt(_wizardVersionKey) ?? 0;
|
||||
|
||||
// Re-show wizard if version has changed (for major updates)
|
||||
return completed && version >= _currentWizardVersion;
|
||||
}
|
||||
|
||||
/// Mark the welcome wizard as completed
|
||||
static Future<void> setWizardCompleted(bool completed) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_wizardCompletedKey, completed);
|
||||
|
||||
if (completed) {
|
||||
// Store current version when wizard is completed
|
||||
await prefs.setInt(_wizardVersionKey, _currentWizardVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the wizard version last shown to the user
|
||||
static Future<int> getWizardVersion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_wizardVersionKey) ?? 0;
|
||||
}
|
||||
|
||||
/// Reset wizard state (useful for testing or re-showing tutorial)
|
||||
static Future<void> resetWizard() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_wizardCompletedKey, false);
|
||||
await prefs.setInt(_wizardVersionKey, 0);
|
||||
}
|
||||
}
|
||||
73
lib/services/wms_tile_provider.dart
Normal file
73
lib/services/wms_tile_provider.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Custom tile provider that logs WMS URLs for debugging
|
||||
class DebugWmsTileProvider extends TileProvider {
|
||||
final http.Client httpClient;
|
||||
|
||||
DebugWmsTileProvider() : httpClient = http.Client();
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
return DebugNetworkTileProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
httpClient.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class DebugNetworkTileProvider extends ImageProvider<DebugNetworkTileProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
final TileLayer options;
|
||||
final http.Client httpClient;
|
||||
|
||||
const DebugNetworkTileProvider({
|
||||
required this.coordinates,
|
||||
required this.options,
|
||||
required this.httpClient,
|
||||
});
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(DebugNetworkTileProvider key, ImageDecoderCallback decode) {
|
||||
// Get the WMS URL from the tile layer options
|
||||
final wmsOptions = options.wmsOptions;
|
||||
if (wmsOptions == null) {
|
||||
throw Exception('WMSTileLayerOptions is required for DebugWmsTileProvider');
|
||||
}
|
||||
|
||||
// Build the WMS URL
|
||||
final url = wmsOptions.getUrl(coordinates, 256, false);
|
||||
|
||||
// Log the URL for debugging
|
||||
debugPrint('🌐 WMS Request URL: $url');
|
||||
|
||||
// Use NetworkImage to load the tile
|
||||
return NetworkImage(url, headers: {'User-Agent': 'MeshCore SAR'})
|
||||
.loadImage(NetworkImage(url), decode);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DebugNetworkTileProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DebugNetworkTileProvider>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is DebugNetworkTileProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.options == options;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, options);
|
||||
}
|
||||
Reference in New Issue
Block a user