feat: MeshCore SAR - Flutter BLE mesh radio companion app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-02-28 10:11:33 +01:00
commit baf49d27d8
313 changed files with 101770 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_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';
MeshCoreBleService? _bleService;
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
///
/// Note: This is foreground tracking. For true background operation,
/// additional platform-specific configuration is required.
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;
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
// Start listening to position updates
Position? lastPosition;
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
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(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_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;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
debugPrint('✅ [BackgroundLocation] Tracking stopped');
}
/// 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(_prefKeyDistance, distance);
debugPrint(
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
);
// Restart tracking if currently enabled
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
if (isEnabled && _bleService != null) {
await stopTracking();
await startTracking(distanceThreshold: distance);
}
}
}

View File

@@ -0,0 +1,308 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// Type of response expected from a command
enum CommandResponseType {
/// No response expected (fire-and-forget)
none,
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
ack,
/// Wait for specific response code with data
data,
}
/// Represents a queued BLE command
class QueuedCommand<T> {
/// The command data to send
final Uint8List data;
/// Command code (first byte of data)
final int commandCode;
/// Type of response expected
final CommandResponseType responseType;
/// Expected response code (for data type commands)
final int? expectedResponseCode;
/// Completer to signal command completion
final Completer<T> completer;
/// Timeout duration for this command
final Duration timeout;
/// Timestamp when command was enqueued
final DateTime enqueuedAt;
QueuedCommand({
required this.data,
required this.commandCode,
required this.responseType,
this.expectedResponseCode,
required this.completer,
required this.timeout,
}) : enqueuedAt = DateTime.now();
}
/// BLE command queue with mutex lock and inter-command delays
///
/// Ensures that:
/// - Only one command executes at a time
/// - 100ms delay between all commands
/// - Commands can wait for ACK or specific responses
/// - Timeouts are enforced
class BleCommandQueue {
// Queue of pending commands
final List<QueuedCommand> _queue = [];
// Mutex lock using Completer
Completer<void> _lock = Completer<void>()..complete();
// Whether queue is currently processing
bool _isProcessing = false;
// Pending responses mapped by command code
final Map<int, QueuedCommand> _pendingResponses = {};
// Last command execution timestamp
DateTime? _lastCommandTime;
// Minimum delay between commands (milliseconds)
static const int _minDelayMs = 100;
// Callbacks
VoidCallback? onQueueEmpty;
void Function(int queueSize)? onQueueSizeChanged;
/// Enqueue a command and wait for it to complete
///
/// [data] - The command data to send
/// [commandCode] - Command code (first byte)
/// [responseType] - Type of response expected
/// [expectedResponseCode] - For data responses, the expected response code
/// [timeout] - Maximum time to wait for response
///
/// Returns a Future that completes when the command receives its response
/// or throws TimeoutException if timeout expires.
Future<T> enqueue<T>({
required Uint8List data,
required int commandCode,
required CommandResponseType responseType,
int? expectedResponseCode,
Duration? timeout,
}) async {
// Determine timeout based on response type
final cmdTimeout =
timeout ??
(responseType == CommandResponseType.data
? const Duration(seconds: 10)
: const Duration(seconds: 5));
// Create queued command
final command = QueuedCommand<T>(
data: data,
commandCode: commandCode,
responseType: responseType,
expectedResponseCode: expectedResponseCode,
completer: Completer<T>(),
timeout: cmdTimeout,
);
// Add to queue
_queue.add(command);
onQueueSizeChanged?.call(_queue.length);
debugPrint(
'📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})',
);
// Start processing if not already running
if (!_isProcessing) {
_processQueue();
}
// Wait for command to complete or timeout
return command.completer.future.timeout(
cmdTimeout,
onTimeout: () {
debugPrint(
'⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s',
);
_pendingResponses.remove(commandCode);
throw TimeoutException(
'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out',
);
},
);
}
/// Process the command queue
Future<void> _processQueue() async {
if (_isProcessing) return;
_isProcessing = true;
while (_queue.isNotEmpty) {
// Wait for lock
await _lock.future;
// Get next command
final command = _queue.removeAt(0);
onQueueSizeChanged?.call(_queue.length);
try {
// Enforce minimum delay between commands
if (_lastCommandTime != null) {
final elapsed = DateTime.now().difference(_lastCommandTime!);
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
if (remainingDelay > 0) {
debugPrint(
'⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command',
);
await Future.delayed(Duration(milliseconds: remainingDelay));
}
}
// Create new lock for next command
_lock = Completer<void>();
// Register for response if needed
if (command.responseType != CommandResponseType.none) {
final responseKey = command.responseType == CommandResponseType.ack
? command.commandCode
: (command.expectedResponseCode ?? command.commandCode);
_pendingResponses[responseKey] = command;
}
// Execute command (handled by BleCommandSender)
// The completer will be completed by completeCommand() when response arrives
debugPrint(
'📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}',
);
// For fire-and-forget commands, complete immediately
if (command.responseType == CommandResponseType.none) {
command.completer.complete(null);
}
// Update last command time
_lastCommandTime = DateTime.now();
// Release lock after minimum delay
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
if (!_lock.isCompleted) {
_lock.complete();
}
});
} catch (e) {
debugPrint('❌ [CommandQueue] Error processing command: $e');
if (!command.completer.isCompleted) {
command.completer.completeError(e);
}
// Release lock on error
if (!_lock.isCompleted) {
_lock.complete();
}
}
}
_isProcessing = false;
onQueueEmpty?.call();
debugPrint('✅ [CommandQueue] Queue empty');
}
/// Complete a pending command with response data
///
/// Called by BleResponseHandler when a response is received
void completeCommand<T>(int responseCode, T data) {
final command = _pendingResponses.remove(responseCode);
if (command != null) {
debugPrint(
'✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}',
);
if (!command.completer.isCompleted) {
command.completer.complete(data);
}
}
}
/// Complete a pending command with error
///
/// Called by BleResponseHandler when RESP_CODE_ERR is received
void completeCommandWithError(
int commandCode,
String error, {
int? errorCode,
}) {
final command = _pendingResponses.remove(commandCode);
if (command != null) {
debugPrint(
'❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
);
if (!command.completer.isCompleted) {
command.completer.completeError(
Exception('Command failed: $error (error code: $errorCode)'),
);
}
}
}
/// Complete all currently pending commands with an error
///
/// Used when RESP_CODE_ERR arrives without a way to identify which command
/// caused it. Since the queue processes one command at a time, at most one
/// command is pending at any given moment.
void completeCurrentCommandWithError(String error, {int? errorCode}) {
for (final entry in _pendingResponses.entries.toList()) {
final command = _pendingResponses.remove(entry.key);
if (command != null && !command.completer.isCompleted) {
debugPrint(
'❌ [CommandQueue] Command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)',
);
command.completer.completeError(
Exception('Command failed: $error (error code: $errorCode)'),
);
}
}
}
/// Get current queue size
int get queueSize => _queue.length;
/// Get number of pending responses
int get pendingResponseCount => _pendingResponses.length;
/// Check if queue is empty
bool get isEmpty => _queue.isEmpty;
/// Check if queue is processing
bool get isProcessing => _isProcessing;
/// Clear all pending commands (use with caution)
void clear() {
debugPrint(
'🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)',
);
// Complete all pending commands with error
for (final command in _pendingResponses.values) {
if (!command.completer.isCompleted) {
command.completer.completeError(Exception('Queue cleared'));
}
}
_queue.clear();
_pendingResponses.clear();
onQueueSizeChanged?.call(0);
}
/// Dispose resources
void dispose() {
clear();
if (!_lock.isCompleted) {
_lock.complete();
}
}
}

View File

@@ -0,0 +1,230 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../meshcore_opcode_names.dart';
import '../../models/ble_packet_log.dart';
import 'ble_command_queue.dart';
/// Callback types for sender events
typedef OnErrorCallback = void Function(String error);
/// Sends commands to the BLE device
class BleCommandSender {
BluetoothCharacteristic? _rxCharacteristic;
int _txPacketCount = 0;
final List<BlePacketLog> _packetLogs = [];
static const int _maxLogSize = 1000;
// Command queue for serialization and response waiting
final BleCommandQueue _commandQueue = BleCommandQueue();
// Callbacks
OnErrorCallback? onError;
VoidCallback? onTxActivity;
// Getters
int get txPacketCount => _txPacketCount;
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
BleCommandQueue get commandQueue => _commandQueue;
/// Set the RX characteristic to write to
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
_rxCharacteristic = characteristic;
}
/// Write data to RX characteristic (fire-and-forget, no response expected)
///
/// This method is for commands that don't expect any response.
/// The command is queued and executed with proper spacing, but we don't wait
/// for any acknowledgment.
Future<void> writeData(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue the command (fire-and-forget)
await _commandQueue.enqueue<void>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.none,
);
// Actually send the data
await _sendToDevice(data);
}
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
///
/// This method should be used for setup commands that return RESP_CODE_OK (0)
/// on success or RESP_CODE_ERR (1) on failure.
///
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
Future<void> writeDataAndWaitForAck(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue command but don't await yet — data must be sent to the device
// before it can respond with an ACK. Awaiting before send would deadlock.
final ackFuture = _commandQueue.enqueue<void>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.ack,
);
// Actually send the data
await _sendToDevice(data);
// Now wait for the ACK response
return ackFuture;
}
/// Write data and wait for specific response
///
/// This method should be used for query commands that return specific data.
///
/// Examples:
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
/// - CMD_APP_START → RESP_CODE_SELF_INFO
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
Future<T> writeDataAndWaitForResponse<T>(
Uint8List data,
int expectedResponseCode,
) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
final commandCode = data.isNotEmpty ? data[0] : 0;
// Enqueue the command (wait for specific response)
final responseFuture = _commandQueue.enqueue<T>(
data: data,
commandCode: commandCode,
responseType: CommandResponseType.data,
expectedResponseCode: expectedResponseCode,
);
// Actually send the data
await _sendToDevice(data);
// Wait for response
return responseFuture;
}
/// Internal method to actually send data to the BLE device
Future<void> _sendToDevice(Uint8List data) async {
if (_rxCharacteristic == null) {
throw Exception('Not connected');
}
try {
// Extract command code from first byte
final commandCode = data.isNotEmpty ? data[0] : null;
final opcodeName = commandCode != null
? MeshCoreOpcodeNames.getCommandName(commandCode)
: 'UNKNOWN';
final opcodeHex = commandCode != null
? '0x${commandCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'
: 'N/A';
debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)');
debugPrint(' Data size: ${data.length} bytes');
debugPrint(
' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
// Check if the characteristic supports write without response
final supportsWriteWithoutResponse =
_rxCharacteristic!.properties.writeWithoutResponse;
final supportsWrite = _rxCharacteristic!.properties.write;
if (supportsWriteWithoutResponse) {
await _rxCharacteristic!.write(data, withoutResponse: true);
} else if (supportsWrite) {
await _rxCharacteristic!.write(data, withoutResponse: false);
} else {
throw Exception('Characteristic does not support write operations');
}
// Log TX packet
_logPacket(data, PacketDirection.tx, responseCode: commandCode);
// Increment TX packet counter and trigger activity indicator
_txPacketCount++;
onTxActivity?.call();
debugPrint('✅ [TX] Command sent successfully');
} catch (e) {
debugPrint('❌ [TX] Write error: $e');
onError?.call('Write error: $e');
rethrow;
}
}
/// Log a packet
void _logPacket(
Uint8List data,
PacketDirection direction, {
int? responseCode,
}) {
// Add new packet
_packetLogs.add(
BlePacketLog(
timestamp: DateTime.now(),
rawData: data,
direction: direction,
responseCode: responseCode,
description: _getPacketDescription(responseCode),
),
);
// Limit log size to prevent memory issues
if (_packetLogs.length > _maxLogSize) {
_packetLogs.removeAt(0);
}
}
/// Get human-readable description of packet
String? _getPacketDescription(int? code) {
// TX packets - command codes
switch (code) {
case 4: // cmdGetContacts
return 'Get Contacts';
case 2: // cmdSendTxtMsg
return 'Send Text Message';
case 3: // cmdSendChannelTxtMsg
return 'Send Channel Message';
case 39: // cmdSendTelemetryReq
return 'Request Telemetry';
case 22: // cmdDeviceQuery
return 'Device Query';
case 1: // cmdAppStart
return 'App Start';
case 27: // cmdSendStatusReq
return 'Status Request';
default:
return null;
}
}
/// Reset packet counter
void resetCounter() {
_txPacketCount = 0;
}
/// Clear packet logs
void clearPacketLogs() {
_packetLogs.clear();
}
/// Dispose resources
void dispose() {
_commandQueue.dispose();
_rxCharacteristic = null;
_packetLogs.clear();
}
}

View File

@@ -0,0 +1,398 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../meshcore_constants.dart';
/// Callback types for connection events
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnErrorCallback = void Function(String error);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// Manages BLE connection lifecycle with automatic reconnection
class BleConnectionManager {
BluetoothDevice? _device;
BluetoothCharacteristic? _rxCharacteristic;
BluetoothCharacteristic? _txCharacteristic;
bool _isConnected = false;
// Reconnection state
bool _reconnectionEnabled = true;
bool _isReconnecting = false;
int _reconnectionAttempt = 0;
Timer? _reconnectionTimer;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
// RSSI monitoring
Timer? _rssiTimer;
int? _lastRssi;
// SAR-optimized reconnection: ~15 minutes total
// Pattern: Fast retries first (for temporary issues), then slower retries (for extended disconnections)
static const int _maxReconnectionAttempts = 30;
static const List<int> _reconnectionDelaysMs = [
2000, // 2s - immediate retry
3000, // 3s - quick retry
5000, // 5s - fast retry
10000, // 10s - moderate retry
15000, // 15s - longer retry
30000, // 30s - extended retry
30000, // 30s - keep trying every 30s after this
]; // Total: ~15 minutes of reconnection attempts
// Callbacks
OnConnectionStateCallback? onConnectionStateChanged;
OnErrorCallback? onError;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnRssiUpdateCallback? onRssiUpdate;
// Getters
bool get isConnected => _isConnected;
bool get isReconnecting => _isReconnecting;
int get reconnectionAttempt => _reconnectionAttempt;
int get maxReconnectionAttempts => _maxReconnectionAttempts;
BluetoothDevice? get device => _device;
BluetoothCharacteristic? get rxCharacteristic => _rxCharacteristic;
BluetoothCharacteristic? get txCharacteristic => _txCharacteristic;
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) async* {
try {
debugPrint('🔍 [BLE] Starting scan for MeshCore devices...');
debugPrint(' Service UUID: ${MeshCoreConstants.bleServiceUuid}');
debugPrint(' Timeout: ${timeout.inSeconds}s');
await FlutterBluePlus.startScan(
timeout: timeout,
withServices: [Guid(MeshCoreConstants.bleServiceUuid)],
);
debugPrint('✅ [BLE] Scan started successfully');
int deviceCount = 0;
await for (final scanResult in FlutterBluePlus.scanResults) {
debugPrint(
'📡 [BLE] Scan results batch received: ${scanResult.length} results',
);
for (final result in scanResult) {
debugPrint(
' Device: ${result.device.platformName} (${result.device.remoteId})',
);
debugPrint(' RSSI: ${result.rssi}');
debugPrint(' Service UUIDs: ${result.advertisementData.serviceUuids}');
if (result.advertisementData.serviceUuids.contains(
Guid(MeshCoreConstants.bleServiceUuid),
)) {
deviceCount++;
debugPrint(' ✅ MeshCore device found! Total: $deviceCount');
yield result;
} else {
debugPrint(' ❌ Not a MeshCore device (service UUID mismatch)');
}
}
}
debugPrint('🏁 [BLE] Scan completed. Found $deviceCount MeshCore devices');
} catch (e) {
debugPrint('❌ [BLE] Scan error: $e');
onError?.call('Scan error: $e');
}
}
/// Connect to a MeshCore device
Future<bool> connect(BluetoothDevice device) async {
try {
debugPrint(
'🔵 [BLE] Starting connection to device: ${device.platformName} (${device.remoteId})',
);
_device = device;
// Connect to device
debugPrint('🔵 [BLE] Calling device.connect() with 15s timeout...');
await device.connect(
license: License.free,
timeout: const Duration(seconds: 15),
mtu: 512,
);
debugPrint('✅ [BLE] Device connected successfully');
// Discover services
debugPrint('🔵 [BLE] Discovering services...');
final services = await device.discoverServices();
debugPrint('✅ [BLE] Found ${services.length} services');
// Log all discovered services for debugging
for (final service in services) {
debugPrint(' 📋 Service: ${service.uuid}');
for (final char in service.characteristics) {
debugPrint(' - Characteristic: ${char.uuid}');
}
}
// Find MeshCore service
debugPrint(
'🔵 [BLE] Looking for MeshCore service: ${MeshCoreConstants.bleServiceUuid}',
);
BluetoothService? meshCoreService;
for (final service in services) {
if (service.uuid.toString().toLowerCase() ==
MeshCoreConstants.bleServiceUuid.toLowerCase()) {
meshCoreService = service;
debugPrint('✅ [BLE] Found MeshCore service');
break;
}
}
if (meshCoreService == null) {
debugPrint('❌ [BLE] MeshCore service not found!');
throw Exception('MeshCore service not found');
}
// Find RX and TX characteristics
debugPrint('🔵 [BLE] Looking for RX and TX characteristics...');
debugPrint(' RX UUID: ${MeshCoreConstants.bleCharacteristicRxUuid}');
debugPrint(' TX UUID: ${MeshCoreConstants.bleCharacteristicTxUuid}');
for (final characteristic in meshCoreService.characteristics) {
final uuid = characteristic.uuid.toString().toLowerCase();
debugPrint(' 📋 Checking characteristic: $uuid');
if (uuid == MeshCoreConstants.bleCharacteristicRxUuid.toLowerCase()) {
_rxCharacteristic = characteristic;
debugPrint(' ✅ Found RX characteristic');
} else if (uuid ==
MeshCoreConstants.bleCharacteristicTxUuid.toLowerCase()) {
_txCharacteristic = characteristic;
debugPrint(' ✅ Found TX characteristic');
}
}
if (_rxCharacteristic == null || _txCharacteristic == null) {
debugPrint('❌ [BLE] Required characteristics not found!');
debugPrint(' RX found: ${_rxCharacteristic != null}');
debugPrint(' TX found: ${_txCharacteristic != null}');
throw Exception('Required characteristics not found');
}
// Enable notifications on TX characteristic
debugPrint('🔵 [BLE] Enabling notifications on TX characteristic...');
await _txCharacteristic!.setNotifyValue(true);
debugPrint('✅ [BLE] Notifications enabled');
_isConnected = true;
_reconnectionAttempt =
0; // Reset reconnection counter on successful connection
debugPrint('🔵 [BLE] Notifying connection state change: connected');
onConnectionStateChanged?.call(true);
// Monitor connection state for automatic reconnection
_setupConnectionMonitoring();
// Start RSSI monitoring
_startRssiMonitoring();
debugPrint('✅✅✅ [BLE] Connection completed successfully!');
return true;
} catch (e) {
debugPrint('❌❌❌ [BLE] Connection failed: $e');
debugPrint('Stack trace: ${StackTrace.current}');
onError?.call('Connection error: $e');
_isConnected = false;
onConnectionStateChanged?.call(false);
return false;
}
}
/// Disconnect from device
Future<void> disconnect() async {
try {
debugPrint('🔴 [BLE] Disconnect requested by user');
// Disable reconnection before disconnecting
_reconnectionEnabled = false;
_cancelReconnection();
_stopRssiMonitoring();
await _device?.disconnect();
_isConnected = false;
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
onConnectionStateChanged?.call(false);
} catch (e) {
onError?.call('Disconnect error: $e');
}
}
/// Setup connection monitoring for automatic reconnection
void _setupConnectionMonitoring() {
debugPrint(
'🔵 [BLE] Setting up connection monitoring for device: ${_device?.platformName}',
);
// Cancel any existing subscription
_connectionStateSubscription?.cancel();
// Monitor connection state changes
_connectionStateSubscription = _device?.connectionState.listen((state) {
debugPrint('🔔 [BLE] Connection state changed: $state');
if (state == BluetoothConnectionState.disconnected) {
debugPrint('⚠️ [BLE] Device disconnected unexpectedly!');
_isConnected = false;
onConnectionStateChanged?.call(false);
// Attempt automatic reconnection if enabled
if (_reconnectionEnabled && !_isReconnecting) {
debugPrint('🔄 [BLE] Starting automatic reconnection...');
_attemptReconnection();
}
} else if (state == BluetoothConnectionState.connected) {
debugPrint('✅ [BLE] Device connected');
_isConnected = true;
_reconnectionAttempt = 0;
_isReconnecting = false;
onConnectionStateChanged?.call(true);
}
});
}
/// Attempt to reconnect to the device
Future<void> _attemptReconnection() async {
if (_device == null || _isReconnecting || !_reconnectionEnabled) {
return;
}
_isReconnecting = true;
_reconnectionAttempt++;
debugPrint(
'🔄 [BLE] Reconnection attempt $_reconnectionAttempt of $_maxReconnectionAttempts',
);
onReconnectionAttempt?.call(_reconnectionAttempt, _maxReconnectionAttempts);
if (_reconnectionAttempt > _maxReconnectionAttempts) {
debugPrint(
'❌ [BLE] Max reconnection attempts reached after ~15 minutes. Giving up.',
);
_isReconnecting = false;
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
);
return;
}
// Calculate delay with exponential backoff (uses last delay for attempts beyond array length)
final delayIndex = (_reconnectionAttempt - 1).clamp(
0,
_reconnectionDelaysMs.length - 1,
);
final delayMs = _reconnectionDelaysMs[delayIndex];
debugPrint(
'🔄 [BLE] Waiting ${(delayMs / 1000).toStringAsFixed(0)}s before reconnection attempt $_reconnectionAttempt...',
);
// Wait before attempting reconnection
_reconnectionTimer = Timer(Duration(milliseconds: delayMs), () async {
if (!_reconnectionEnabled) {
debugPrint('🔄 [BLE] Reconnection cancelled by user');
_isReconnecting = false;
return;
}
try {
debugPrint('🔄 [BLE] Attempting to reconnect...');
// Try to reconnect
final success = await connect(_device!);
if (success) {
debugPrint('✅ [BLE] Reconnection successful!');
_isReconnecting = false;
_reconnectionAttempt = 0;
} else {
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt failed');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes ($_maxReconnectionAttempts attempts).',
);
}
}
} catch (e) {
debugPrint('❌ [BLE] Reconnection attempt $_reconnectionAttempt error: $e');
_isReconnecting = false;
// Try again if we haven't reached max attempts
if (_reconnectionAttempt < _maxReconnectionAttempts) {
_attemptReconnection();
} else {
onError?.call(
'Connection lost. Unable to reconnect after 15 minutes: $e',
);
}
}
});
}
/// Cancel ongoing reconnection attempts
void _cancelReconnection() {
debugPrint('🔴 [BLE] Cancelling reconnection attempts');
_reconnectionTimer?.cancel();
_reconnectionTimer = null;
_isReconnecting = false;
_reconnectionAttempt = 0;
_connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
}
/// Enable automatic reconnection (useful after user manually disconnects)
void enableReconnection() {
debugPrint('🔵 [BLE] Re-enabling automatic reconnection');
_reconnectionEnabled = true;
}
/// Start monitoring RSSI in the background
void _startRssiMonitoring() {
debugPrint('📡 [BLE] Starting RSSI monitoring (every 5 seconds)');
_stopRssiMonitoring(); // Cancel any existing timer
_rssiTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (_device != null && _isConnected) {
try {
final rssi = await _device!.readRssi();
if (_lastRssi != rssi) {
_lastRssi = rssi;
onRssiUpdate?.call(rssi);
}
} catch (e) {
debugPrint('⚠️ [BLE] Failed to read RSSI: $e');
}
}
});
}
/// Stop RSSI monitoring
void _stopRssiMonitoring() {
_rssiTimer?.cancel();
_rssiTimer = null;
_lastRssi = null;
debugPrint('📡 [BLE] RSSI monitoring stopped');
}
/// Dispose resources
void dispose() {
debugPrint('🔴 [BLE] Disposing BLE connection manager');
_cancelReconnection();
_stopRssiMonitoring();
_device = null;
_rxCharacteristic = null;
_txCharacteristic = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer reader for parsing MeshCore protocol binary data
class BufferReader {
final Uint8List _buffer;
/// Current read position in the buffer
int offset = 0;
BufferReader(this._buffer);
/// Get remaining bytes count
int get remainingBytesCount => _buffer.length - offset;
/// Check if there are bytes remaining
bool get hasRemaining => offset < _buffer.length;
/// Read a single byte (uint8)
int readByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
return _buffer[offset++];
}
/// Read a signed byte (int8)
int readInt8() {
final value = readByte();
return value > 127 ? value - 256 : value;
}
/// Read unsigned 16-bit integer (little-endian)
int readUInt16LE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] | (_buffer[offset + 1] << 8);
offset += 2;
return value;
}
/// Read signed 16-bit integer (little-endian)
int readInt16LE() {
final value = readUInt16LE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 16-bit integer (big-endian)
int readUInt16BE() {
if (offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = (_buffer[offset] << 8) | _buffer[offset + 1];
offset += 2;
return value;
}
/// Read signed 16-bit integer (big-endian)
int readInt16BE() {
final value = readUInt16BE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 32-bit integer (little-endian)
int readUInt32LE() {
if (offset + 4 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[offset] |
(_buffer[offset + 1] << 8) |
(_buffer[offset + 2] << 16) |
(_buffer[offset + 3] << 24);
offset += 4;
return value;
}
/// Read signed 32-bit integer (little-endian)
int readInt32LE() {
final value = readUInt32LE();
return value > 2147483647 ? value - 4294967296 : value;
}
/// Read a fixed number of bytes
Uint8List readBytes(int length) {
if (offset + length > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + length);
offset += length;
return bytes;
}
/// Read remaining bytes
Uint8List readRemainingBytes() {
final bytes = _buffer.sublist(offset);
offset = _buffer.length;
return bytes;
}
/// Read null-terminated string (C-string) with max length
String readCString(int maxLength) {
if (offset + maxLength > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(offset, offset + maxLength);
offset += maxLength;
// Find null terminator
int nullIndex = bytes.indexOf(0);
if (nullIndex == -1) {
nullIndex = maxLength;
}
// Decode string up to null terminator
return utf8.decode(bytes.sublist(0, nullIndex));
}
/// Read length-prefixed string (remaining bytes as UTF-8)
String readString() {
final bytes = readRemainingBytes();
return utf8.decode(bytes);
}
/// Peek at next byte without advancing offset
int peekByte() {
if (offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
}
return _buffer[offset];
}
/// Skip bytes
void skip(int count) {
if (offset + count > _buffer.length) {
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
}
offset += count;
}
/// Reset offset to beginning
void reset() {
offset = 0;
}
@override
String toString() {
return 'BufferReader(length: ${_buffer.length}, offset: $offset, remaining: $remainingBytesCount)';
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer writer for creating MeshCore protocol binary data
class BufferWriter {
final List<int> _buffer = [];
/// Get current buffer length
int get length => _buffer.length;
/// Write a single byte (uint8)
void writeByte(int value) {
if (value < 0 || value > 255) {
throw ArgumentError('Byte value must be between 0 and 255');
}
_buffer.add(value);
}
/// Write a signed byte (int8)
void writeInt8(int value) {
if (value < -128 || value > 127) {
throw ArgumentError('Int8 value must be between -128 and 127');
}
_buffer.add(value < 0 ? value + 256 : value);
}
/// Write unsigned 16-bit integer (little-endian)
void writeUInt16LE(int value) {
if (value < 0 || value > 65535) {
throw ArgumentError('UInt16 value must be between 0 and 65535');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
}
/// Write signed 16-bit integer (little-endian)
void writeInt16LE(int value) {
if (value < -32768 || value > 32767) {
throw ArgumentError('Int16 value must be between -32768 and 32767');
}
final unsigned = value < 0 ? value + 65536 : value;
writeUInt16LE(unsigned);
}
/// Write unsigned 32-bit integer (little-endian)
void writeUInt32LE(int value) {
if (value < 0 || value > 4294967295) {
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
_buffer.add((value >> 16) & 0xFF);
_buffer.add((value >> 24) & 0xFF);
}
/// Write signed 32-bit integer (little-endian)
void writeInt32LE(int value) {
if (value < -2147483648 || value > 2147483647) {
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
}
final unsigned = value < 0 ? value + 4294967296 : value;
writeUInt32LE(unsigned);
}
/// Write bytes from Uint8List
void writeBytes(Uint8List bytes) {
_buffer.addAll(bytes);
}
/// Write bytes from `List<int>`
void writeBytesFromList(List<int> bytes) {
_buffer.addAll(bytes);
}
/// Write null-terminated string (C-string) with fixed length
/// Pads with zeros if string is shorter than maxLength
void writeCString(String str, int maxLength) {
final bytes = utf8.encode(str);
// Ensure we don't exceed max length
final length = bytes.length < maxLength ? bytes.length : maxLength;
// Write string bytes
for (int i = 0; i < length; i++) {
_buffer.add(bytes[i]);
}
// Pad with zeros
for (int i = length; i < maxLength; i++) {
_buffer.add(0);
}
}
/// Write length-prefixed string
void writeString(String str) {
final bytes = utf8.encode(str);
_buffer.addAll(bytes);
}
/// Write string with length prefix (1 byte)
void writeLengthPrefixedString(String str) {
final bytes = utf8.encode(str);
if (bytes.length > 255) {
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
}
writeByte(bytes.length);
_buffer.addAll(bytes);
}
/// Get buffer as Uint8List
Uint8List toBytes() {
return Uint8List.fromList(_buffer);
}
/// Clear the buffer
void clear() {
_buffer.clear();
}
/// Get buffer as hex string (for debugging)
String toHexString() {
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
@override
String toString() {
return 'BufferWriter(length: $length, hex: ${toHexString()})';
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'package:flutter/foundation.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 (!Platform.isAndroid) {
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;
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact_telemetry.dart';
import 'buffer_reader.dart';
import 'meshcore_constants.dart';
/// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices
class CayenneLppParser {
/// 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? temperature;
double? humidity;
double? pressure;
final extraSensorData = <String, dynamic>{};
int fieldCount = 0;
while (reader.hasRemaining) {
try {
fieldCount++;
debugPrint(
' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}',
);
final channel = reader.readByte();
debugPrint(' Channel: $channel');
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');
extraSensorData['analog_input_$channel'] = value;
// If this is a battery reading
if (channel == 0 || channel == 1) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
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();
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();
temperature = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue');
debugPrint(
' Temperature: ${temperature.toStringAsFixed(1)}°C',
);
break;
case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte();
humidity = rawValue / 2.0;
debugPrint(' Humidity (raw): $rawValue');
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
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();
pressure = rawValue / 10.0;
debugPrint(' Barometer (raw): $rawValue');
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
break;
case MeshCoreConstants.lppVoltageSensor:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V');
// Treat voltage sensor as battery reading
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
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['altitude_$channel'] = alt;
break;
default:
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
);
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount);
break;
}
} catch (e) {
debugPrint(' ❌ Parsing error: $e');
// If we encounter a parsing error, break and return what we have
break;
}
}
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;
}
/// 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);
}
}

View File

@@ -0,0 +1,207 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.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 int _maxStoredContacts = 500; // Store up to 500 contacts
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) 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(_contactsKey, 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}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
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() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_contactsKey);
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
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,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
};
}
/// 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,
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,
);
} 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;
}
}
}

View 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')}';
}
}

View File

@@ -0,0 +1,90 @@
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
];
/// 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';
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';
default:
return locale.languageCode;
}
}
}

View File

@@ -0,0 +1,517 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'meshcore_ble_service.dart';
/// Centralized location tracking service for MeshCore SAR
///
/// Handles GPS tracking, distance thresholds, background updates,
/// and location broadcasting to the mesh network.
///
/// Features:
/// - Singleton pattern for app-wide access
/// - Configurable distance thresholds (min/max)
/// - Configurable time intervals
/// - Permission handling
/// - SharedPreferences persistence
/// - MeshCore mesh network integration
/// - Real-time position updates via callbacks
class LocationTrackingService {
// ============================================================================
// Singleton Pattern
// ============================================================================
static final LocationTrackingService _instance =
LocationTrackingService._internal();
/// Get the singleton instance
factory LocationTrackingService() => _instance;
LocationTrackingService._internal();
// ============================================================================
// SharedPreferences Keys
// ============================================================================
static const String _prefKeyEnabled = 'background_tracking_enabled';
static const String _prefKeyMinDistance = 'map_gps_min_distance';
static const String _prefKeyMaxDistance = 'map_gps_max_distance';
static const String _prefKeyMinTimeInterval = 'map_gps_min_time_interval';
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon';
// ============================================================================
// Configuration Properties
// ============================================================================
/// Minimum distance in meters before broadcasting update
double minDistanceMeters = 5.0;
/// Maximum distance in meters that forces a broadcast regardless of time
double maxDistanceMeters = 100.0;
/// Minimum time interval in seconds between broadcasts
int minTimeIntervalSeconds = 30;
/// GPS update distance filter for position stream
double gpsUpdateDistance = 10.0;
// ============================================================================
// State Properties
// ============================================================================
/// Current GPS position
Position? currentPosition;
/// Whether tracking is currently active
bool isTracking = false;
/// Whether service has been initialized with BLE service
bool _isInitialized = false;
/// Whether the first stable position has been set (without broadcast)
bool _firstPositionSet = false;
// ============================================================================
// Private Properties
// ============================================================================
/// Reference to MeshCore BLE service for broadcasting
MeshCoreBleService? _bleService;
/// Position stream subscription
StreamSubscription<Position>? _positionSubscription;
// ============================================================================
// Callback Properties
// ============================================================================
/// Called when position is updated
void Function(Position)? onPositionUpdate;
/// Called when an error occurs
void Function(String error)? onError;
/// Called when a location broadcast is sent to mesh network
void Function(Position)? onBroadcastSent;
/// Called when tracking state changes
void Function(bool isTracking)? onTrackingStateChanged;
// ============================================================================
// Initialization
// ============================================================================
/// Initialize the service with MeshCore BLE service reference
///
/// Must be called before starting tracking.
Future<bool> initialize(MeshCoreBleService bleService) async {
_bleService = bleService;
_isInitialized = true;
// Load saved settings
await loadSettings();
debugPrint('✅ [LocationTracking] Service initialized');
return true;
}
// ============================================================================
// Permission Handling
// ============================================================================
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
/// Request location permissions from user
///
/// Returns true if granted, false otherwise.
Future<bool> requestPermissions() async {
// Check if location service is enabled
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
onError?.call('Location services are disabled');
return false;
}
// Check current permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
onError?.call('Location permission denied');
return false;
}
}
if (permission == LocationPermission.deniedForever) {
onError?.call(
'Location permission permanently denied. Please enable in settings.',
);
return false;
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
// ============================================================================
// GPS Position Methods
// ============================================================================
/// Get current GPS position
///
/// Returns null if position unavailable or permissions denied.
/// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
/// [retryCount] - Number of retry attempts (default: 2)
Future<Position?> getCurrentPosition({
Duration timeLimit = const Duration(seconds: 15),
int retryCount = 2,
}) async {
for (int attempt = 0; attempt <= retryCount; attempt++) {
try {
if (attempt > 0) {
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
// Exponential backoff: wait 2^attempt seconds before retry
await Future.delayed(Duration(seconds: 1 << attempt));
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
timeLimit: timeLimit,
),
);
currentPosition = position;
if (attempt > 0) {
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
}
return position;
} catch (e) {
final isLastAttempt = attempt == retryCount;
if (isLastAttempt) {
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
// Only call error callback on final failure, and make it user-friendly
if (e.toString().contains('TimeoutException')) {
onError?.call('GPS signal weak. Position stream will continue trying...');
} else {
onError?.call('Failed to get GPS position. Check device settings.');
}
} else {
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
}
if (isLastAttempt) {
return null;
}
}
}
return null;
}
/// Get position stream with configurable distance filter
///
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter.toInt(),
),
);
}
// ============================================================================
// Tracking Control
// ============================================================================
/// Start location tracking
///
/// [distanceThreshold] - GPS update distance filter
///
/// Returns true if successful, false otherwise.
/// Note: This method returns immediately after starting the position stream.
/// Initial position acquisition happens asynchronously in the background.
///
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
Future<bool> startTracking({double? distanceThreshold}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [LocationTracking] Service not initialized',
);
onError?.call('Location tracking service not initialized');
return false;
}
// Allow tracking without BLE connection - broadcasts will be skipped
if (_bleService == null || !_bleService!.isConnected) {
debugPrint(' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
}
// Check permissions
final hasPermission = await requestPermissions();
if (!hasPermission) {
return false;
}
// Use provided threshold or current setting
final threshold = distanceThreshold ?? gpsUpdateDistance;
gpsUpdateDistance = threshold;
// Save settings
await saveSettings();
// Try to get initial position in background (non-blocking)
// This will populate currentPosition but won't block tracking startup
getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
).then((position) {
if (position != null) {
debugPrint('✅ [LocationTracking] Initial position acquired in background');
}
}).catchError((error) {
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
// Not critical - position stream will eventually provide position
});
// Start position stream immediately (don't wait for initial position)
try {
_positionSubscription = getPositionStream(distanceFilter: threshold)
.listen(
_handlePositionUpdate,
onError: (error) {
debugPrint('❌ [LocationTracking] Position stream error: $error');
onError?.call('GPS stream error. Retrying...');
},
);
isTracking = true;
onTrackingStateChanged?.call(true);
debugPrint(
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
);
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
return true;
} catch (e) {
debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
onError?.call('Failed to start GPS tracking: $e');
return false;
}
}
/// Stop location tracking
Future<void> stopTracking() async {
debugPrint('🛑 [LocationTracking] Stopping tracking');
await _positionSubscription?.cancel();
_positionSubscription = null;
isTracking = false;
onTrackingStateChanged?.call(false);
// Reset first position flag so next connection starts fresh
_firstPositionSet = false;
// Save disabled state
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
debugPrint('✅ [LocationTracking] Tracking stopped');
}
/// Update the distance threshold and restart tracking if active
Future<void> updateDistanceThreshold(double meters) async {
gpsUpdateDistance = meters;
await saveSettings();
debugPrint(
'📏 [LocationTracking] Distance threshold updated to ${meters}m',
);
// Restart tracking if currently active
if (isTracking) {
await stopTracking();
await startTracking(distanceThreshold: meters);
}
}
// ============================================================================
// Position Update Handler
// ============================================================================
/// Handle incoming position updates from GPS stream
void _handlePositionUpdate(Position position) {
debugPrint(
'📍 [LocationTracking] New position: ${position.latitude}, ${position.longitude}',
);
// Update current position
currentPosition = position;
// Notify listeners
onPositionUpdate?.call(position);
// SPECIAL CASE: First stable position after connection
// Set lat/lon on device WITHOUT broadcasting to mesh network
if (!_firstPositionSet) {
_setInitialPosition(position);
return;
}
// Check if we should broadcast to mesh network
_checkAndBroadcast(position);
}
/// Set initial position on device without broadcasting
///
/// Called only for the first stable GPS position after connection starts.
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
void _setInitialPosition(Position position) async {
if (_bleService == null || !_bleService!.isConnected) {
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
return;
}
try {
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
// Update device's advertised location WITHOUT sending advertisement
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Mark first position as set
_firstPositionSet = true;
// Save to preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
} catch (e) {
debugPrint('⚠️ [LocationTracking] Failed to set initial position: $e');
debugPrint(' Will retry on next GPS update');
// Don't mark as set on failure, so it will retry on next update
// Don't call onError - this is not critical since it will retry automatically
}
}
/// Check if position should be broadcast based on distance and time thresholds
/// DISABLED: Automatic broadcasting removed - use advert button for manual broadcasts
void _checkAndBroadcast(Position position) {
// Automatic broadcasting disabled
// Use the manual advert button instead
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
}
// ============================================================================
// Mesh Network Broadcasting
// ============================================================================
/// Manually broadcast current location immediately
///
/// Useful for "Send Location Now" button functionality.
/// Note: Manual broadcasts bypass automatic throttling and can be sent anytime.
/// However, they still update the last broadcast time to maintain proper spacing
/// for subsequent automatic broadcasts.
Future<bool> broadcastLocationNow() async {
if (!_isInitialized || _bleService == null) {
onError?.call('Location tracking service not initialized');
return false;
}
if (!_bleService!.isConnected) {
onError?.call('Not connected to mesh device');
return false;
}
try {
// Get current position
final position = await getCurrentPosition();
if (position == null) {
onError?.call('Failed to get current position');
return false;
}
debugPrint('📤 [LocationTracking] Manual broadcast requested');
// Broadcast regardless of automatic throttling thresholds
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint('✅ [LocationTracking] Manual broadcast successful');
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
onBroadcastSent?.call(position);
return true;
} catch (e) {
debugPrint('❌ [LocationTracking] Manual broadcast failed: $e');
onError?.call('Failed to broadcast location: $e');
return false;
}
}
// ============================================================================
// Settings Persistence
// ============================================================================
/// Load settings from SharedPreferences
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
debugPrint(' Max distance: ${maxDistanceMeters}m');
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
}
/// Save settings to SharedPreferences
Future<void> saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
await prefs.setBool(_prefKeyEnabled, isTracking);
debugPrint('✅ [LocationTracking] Settings saved');
}
// ============================================================================
// Cleanup
// ============================================================================
/// Dispose resources and cleanup
void dispose() {
debugPrint('🗑️ [LocationTracking] Disposing service');
_positionSubscription?.cancel();
_positionSubscription = null;
_bleService = null;
_isInitialized = false;
isTracking = false;
}
}

View File

@@ -0,0 +1,508 @@
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/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,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
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: getContactMarkerColor(contact, context),
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: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
getContactMarkerIcon(contact),
color: Colors.white,
size: 18,
),
),
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,
}) {
return sarMarkers.map((marker) {
return Marker(
point: 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.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.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;
}
}

View File

@@ -0,0 +1,273 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mbtiles/mbtiles.dart';
/// Metadata information extracted from an MBTiles file
class MbtilesMetadata {
final String name;
final String? description;
final String? version;
final String? attribution;
final String? bounds; // "minLon,minLat,maxLon,maxLat"
final String? center; // "lon,lat,zoom"
final int? minZoom;
final int? maxZoom;
final String? format; // "pbf", "png", "jpg", etc.
final String? type; // "overlay", "baselayer"
final String? json; // Additional metadata JSON
final File file;
final int fileSize;
const MbtilesMetadata({
required this.name,
this.description,
this.version,
this.attribution,
this.bounds,
this.center,
this.minZoom,
this.maxZoom,
this.format,
this.type,
this.json,
required this.file,
required this.fileSize,
});
/// Check if this is a vector tile MBTiles file
bool get isVector => format == 'pbf' || format == 'mvt';
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
List<double>? get boundsCoordinates {
if (bounds == null) return null;
try {
final parts = bounds!.split(',');
if (parts.length != 4) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing bounds: $e');
return null;
}
}
/// Parse center string into [lon, lat, zoom]
List<double>? get centerCoordinates {
if (center == null) return null;
try {
final parts = center!.split(',');
if (parts.length < 2) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing center: $e');
return null;
}
}
/// Get file size in human-readable format
String get fileSizeFormatted {
if (fileSize < 1024) {
return '$fileSize B';
} else if (fileSize < 1024 * 1024) {
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
} else if (fileSize < 1024 * 1024 * 1024) {
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}
/// Service for managing MBTiles files for offline vector maps
class MbtilesService {
static const String _mbtilesDirectory = 'offline_maps';
/// Get the directory where MBTiles files are stored
Future<Directory> getMbtilesDirectory() async {
final appDocDir = await getApplicationDocumentsDirectory();
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
// Create directory if it doesn't exist
if (!await mbtilesDir.exists()) {
await mbtilesDir.create(recursive: true);
}
return mbtilesDir;
}
/// List all MBTiles files in the offline maps directory
Future<List<File>> listMbtilesFiles() async {
final dir = await getMbtilesDirectory();
try {
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
.map((entity) => entity as File)
.toList();
return files;
} catch (e) {
debugPrint('Error listing MBTiles files: $e');
return [];
}
}
/// Get metadata from an MBTiles file
Future<MbtilesMetadata?> getMetadata(File file) async {
try {
// Check if file exists
if (!await file.exists()) {
debugPrint('MBTiles file does not exist: ${file.path}');
return null;
}
// Get file size
final fileSize = await file.length();
// Open MBTiles file
final mbtiles = MbTiles(mbtilesPath: file.path);
// Get metadata from MBTiles
final metadata = mbtiles.getMetadata();
// Convert bounds object to string if available
String? boundsStr;
if (metadata.bounds != null) {
boundsStr = metadata.bounds.toString();
}
return MbtilesMetadata(
name: metadata.name,
description: metadata.description,
version: metadata.version?.toString(),
attribution: null, // Not available in new API
bounds: boundsStr,
center: null, // Not available in new API
minZoom: metadata.minZoom?.toInt(),
maxZoom: metadata.maxZoom?.toInt(),
format: metadata.format,
type: metadata.type?.name,
json: null, // Not available in new API
file: file,
fileSize: fileSize,
);
} catch (e) {
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
return null;
}
}
/// Get metadata for all MBTiles files
Future<List<MbtilesMetadata>> getAllMetadata() async {
final files = await listMbtilesFiles();
final metadataList = <MbtilesMetadata>[];
for (final file in files) {
final metadata = await getMetadata(file);
if (metadata != null) {
metadataList.add(metadata);
}
}
return metadataList;
}
/// Import an MBTiles file from an external location
Future<File?> importMbtilesFile(String sourcePath) async {
try {
final sourceFile = File(sourcePath);
// Verify source file exists
if (!await sourceFile.exists()) {
debugPrint('Source file does not exist: $sourcePath');
return null;
}
// Get destination directory
final destDir = await getMbtilesDirectory();
final fileName = _getFileName(sourceFile);
final destPath = '${destDir.path}/$fileName';
// Copy file to destination
final destFile = await sourceFile.copy(destPath);
debugPrint('Imported MBTiles file to: $destPath');
return destFile;
} catch (e) {
debugPrint('Error importing MBTiles file: $e');
return null;
}
}
/// Delete an MBTiles file
Future<bool> deleteMbtilesFile(File file) async {
try {
if (await file.exists()) {
await file.delete();
debugPrint('Deleted MBTiles file: ${file.path}');
return true;
}
return false;
} catch (e) {
debugPrint('Error deleting MBTiles file: $e');
return false;
}
}
/// Check if data in MBTiles is gzip compressed
Future<bool> isGzipCompressed(File file) async {
try {
// Open MBTiles and check a sample tile
final mbtiles = MbTiles(mbtilesPath: file.path);
// Try to get metadata to check for compression hints
final metadata = mbtiles.getMetadata();
final format = metadata.format;
// For Geofabrik files, format is 'pbf' and data is gzipped
// We can infer this from common patterns, but ideally we'd check actual tile data
if (format == 'pbf') {
// Geofabrik MBTiles are typically gzipped
// Could also check tile data headers, but this is a reasonable heuristic
return true;
}
return false;
} catch (e) {
debugPrint('Error checking gzip compression: $e');
return false;
}
}
/// Determine the vector tile schema from metadata
String? getVectorSchema(MbtilesMetadata metadata) {
// Try to infer schema from metadata
final json = metadata.json;
if (json != null) {
if (json.contains('shortbread')) {
return 'shortbread';
} else if (json.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Check description
final description = metadata.description?.toLowerCase();
if (description != null) {
if (description.contains('shortbread')) {
return 'shortbread';
} else if (description.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Default to unknown
return null;
}
/// Helper: Get file name from path
String _getFileName(File file) {
return file.path.split(Platform.pathSeparator).last;
}
}

View File

@@ -0,0 +1,740 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import 'ble/ble_connection_manager.dart';
import 'ble/ble_command_sender.dart';
import 'ble/ble_response_handler.dart';
import 'protocol/frame_builder.dart';
import 'meshcore_constants.dart';
/// Callback types for MeshCore events
typedef OnContactCallback = void Function(Contact contact);
typedef OnContactsCompleteCallback = void Function(List<Contact> contacts);
typedef OnMessageCallback = void Function(Message message);
typedef OnTelemetryCallback =
void Function(Uint8List publicKey, Uint8List lppData);
typedef OnSelfInfoCallback = void Function(Map<String, dynamic> selfInfo);
typedef OnDeviceInfoCallback = void Function(Map<String, dynamic> deviceInfo);
typedef OnNoMoreMessagesCallback = void Function();
typedef OnMessageWaitingCallback = void Function();
typedef OnLoginSuccessCallback =
void Function(
Uint8List publicKeyPrefix,
int permissions,
bool isAdmin,
int tag,
);
typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix);
typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
typedef OnMessageSentCallback = void Function(
int expectedAckTag,
int suggestedTimeoutMs,
bool isFloodMode,
Uint8List? contactPublicKey,
);
typedef OnMessageDeliveredCallback =
void Function(int ackCode, int roundTripTimeMs);
typedef OnMessageEchoDetectedCallback =
void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
typedef OnStatusResponseCallback =
void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback =
void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback =
void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnChannelInfoCallback =
void Function(int channelIdx, String channelName, Uint8List secret, int? flags);
typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback =
void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi);
/// MeshCore BLE Service - coordinates BLE communication components
class MeshCoreBleService {
// Component instances
final BleConnectionManager _connectionManager = BleConnectionManager();
final BleCommandSender _commandSender = BleCommandSender();
final BleResponseHandler _responseHandler = BleResponseHandler();
// Keepalive timer for iOS background mode
Timer? _keepaliveTimer;
static const Duration _keepaliveInterval = Duration(seconds: 20);
// Event callbacks
OnConnectionStateCallback? onConnectionStateChanged;
OnReconnectionAttemptCallback? onReconnectionAttempt;
OnRssiUpdateCallback? onRssiUpdate;
OnContactCallback? onContactReceived;
OnContactsCompleteCallback? onContactsComplete;
OnMessageCallback? onMessageReceived;
OnTelemetryCallback? onTelemetryReceived;
OnSelfInfoCallback? onSelfInfoReceived;
OnDeviceInfoCallback? onDeviceInfoReceived;
OnNoMoreMessagesCallback? onNoMoreMessages;
OnMessageWaitingCallback? onMessageWaiting;
OnLoginSuccessCallback? onLoginSuccess;
OnLoginFailCallback? onLoginFail;
OnAdvertReceivedCallback? onAdvertReceived;
OnPathUpdatedCallback? onPathUpdated;
OnMessageSentCallback? onMessageSent;
OnMessageDeliveredCallback? onMessageDelivered;
OnMessageEchoDetectedCallback? onMessageEchoDetected;
OnStatusResponseCallback? onStatusResponse;
OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
OnChannelInfoCallback? onChannelInfoReceived;
// Activity callbacks (for blinking indicators)
VoidCallback? onRxActivity;
VoidCallback? onTxActivity;
// Constructor
MeshCoreBleService() {
_setupCallbacks();
}
// Setup callbacks between components
void _setupCallbacks() {
// Connection manager callbacks
_connectionManager.onConnectionStateChanged = (isConnected) {
if (isConnected) {
_startKeepalive();
} else {
_stopKeepalive();
}
onConnectionStateChanged?.call(isConnected);
};
_connectionManager.onError = (error) {
onError?.call(error);
};
_connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) {
debugPrint(
'🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts',
);
onReconnectionAttempt?.call(attemptNumber, maxAttempts);
};
_connectionManager.onRssiUpdate = (rssi) {
onRssiUpdate?.call(rssi);
};
// Command sender callbacks
_commandSender.onError = (error) {
onError?.call(error);
};
_commandSender.onTxActivity = () {
onTxActivity?.call();
};
// Response handler callbacks
_responseHandler.onContactReceived = (contact) {
debugPrint('🔔 [BleService] onContactReceived - "${contact.advName}" - forwarding to ConnectionProvider');
onContactReceived?.call(contact);
};
_responseHandler.onContactsComplete = (contacts) {
debugPrint('🔔 [BleService] onContactsComplete - ${contacts.length} contacts - forwarding to ConnectionProvider');
onContactsComplete?.call(contacts);
};
_responseHandler.onMessageReceived = (message) {
debugPrint('🔔 [BleService] onMessageReceived - forwarding to ConnectionProvider');
onMessageReceived?.call(message);
};
_responseHandler.onTelemetryReceived = (publicKey, lppData) {
debugPrint('🔔 [BleService] onTelemetryReceived - ${lppData.length} bytes - forwarding to ConnectionProvider');
onTelemetryReceived?.call(publicKey, lppData);
};
_responseHandler.onSelfInfoReceived = (selfInfo) {
// Extract our node hash (first byte of public key) for echo detection
if (selfInfo['publicKey'] != null) {
final publicKey = selfInfo['publicKey'] as Uint8List;
if (publicKey.isNotEmpty) {
_responseHandler.setOurNodeHash(publicKey[0]);
}
}
onSelfInfoReceived?.call(selfInfo);
};
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
onDeviceInfoReceived?.call(deviceInfo);
};
_responseHandler.onNoMoreMessages = () {
onNoMoreMessages?.call();
};
_responseHandler.onMessageWaiting = () {
onMessageWaiting?.call();
};
_responseHandler.onLoginSuccess =
(publicKeyPrefix, permissions, isAdmin, tag) {
onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag);
};
_responseHandler.onLoginFail = (publicKeyPrefix) {
onLoginFail?.call(publicKeyPrefix);
};
_responseHandler.onAdvertReceived = (publicKey) {
debugPrint('🔔 [BleService] onAdvertReceived - forwarding to ConnectionProvider');
onAdvertReceived?.call(publicKey);
};
_responseHandler.onPathUpdated = (publicKey) {
debugPrint('🔔 [BleService] onPathUpdated - forwarding to ConnectionProvider');
onPathUpdated?.call(publicKey);
};
_responseHandler.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey);
};
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
onMessageDelivered?.call(ackCode, roundTripTimeMs);
};
_responseHandler.onMessageEchoDetected =
(messageId, echoCount, snrRaw, rssiDbm) {
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
};
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
onStatusResponse?.call(publicKeyPrefix, statusData);
};
_responseHandler.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
onBinaryResponse?.call(publicKeyPrefix, tag, responseData);
};
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
};
_responseHandler.onError = (error, {int? errorCode}) {
onError?.call(error, errorCode: errorCode);
};
_responseHandler.onContactNotFound = (contactPublicKey) {
onContactNotFound?.call(contactPublicKey);
};
_responseHandler.onChannelInfoReceived = (int channelIdx, String channelName, Uint8List secret, int? flags) {
onChannelInfoReceived?.call(channelIdx, channelName, secret, flags);
};
_responseHandler.onRxActivity = () {
onRxActivity?.call();
};
}
// Getters
bool get isConnected => _connectionManager.isConnected;
bool get isReconnecting => _connectionManager.isReconnecting;
int get reconnectionAttempt => _connectionManager.reconnectionAttempt;
int get maxReconnectionAttempts => _connectionManager.maxReconnectionAttempts;
int get rxPacketCount => _responseHandler.rxPacketCount;
int get txPacketCount => _commandSender.txPacketCount;
List<BlePacketLog> get packetLogs {
// Merge logs from both sender and handler
final allLogs = [
..._commandSender.packetLogs,
..._responseHandler.packetLogs,
];
allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return allLogs;
}
/// Scan for MeshCore devices
Stream<ScanResult> scanForDevices({
Duration timeout = const Duration(seconds: 10),
}) {
return _connectionManager.scanForDevices(timeout: timeout);
}
/// Connect to a MeshCore device
Future<bool> connect(BluetoothDevice device) async {
final success = await _connectionManager.connect(device);
if (success) {
try {
// Setup command sender with RX characteristic
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
// Wire up command queue between sender and response handler
_responseHandler.setCommandQueue(_commandSender.commandQueue);
// Setup response handler with TX characteristic
if (_connectionManager.txCharacteristic != null) {
_responseHandler.subscribeToNotifications(
_connectionManager.txCharacteristic!,
);
}
// Send initial device query and wait for responses
await _sendDeviceQuery();
debugPrint('✅ [Service] Device initialization complete');
return true;
} catch (e) {
debugPrint('❌ [Service] Device initialization failed: $e');
// Disconnect on initialization failure
await disconnect();
onError?.call('Device initialization failed: $e');
return false;
}
}
return success;
}
/// Disconnect from device
Future<void> disconnect() async {
await _connectionManager.disconnect();
}
/// Send initial device query and sync clock
Future<void> _sendDeviceQuery() async {
// STEP 1: Send device query FIRST to get device capabilities
// This is the first command to send per protocol documentation
debugPrint(
'🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...',
);
final deviceInfo = await _commandSender
.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildDeviceQuery(),
MeshCoreConstants.respDeviceInfo,
);
debugPrint(
'✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}',
);
// STEP 2: Send app start to initialize the app session
// This is the first command after connection per protocol documentation
debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...');
await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
FrameBuilder.buildAppStart(),
MeshCoreConstants.respSelfInfo,
);
debugPrint('✅ [Service] Self info received: node initialized');
// STEP 3: Set device clock AFTER initialization
// This ensures the device has correct timestamps for all subsequent operations
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
debugPrint('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
debugPrint('✅ [Service] Device clock sent (no ACK expected)');
// STEP 4: Sync any waiting messages immediately after connection
// This ensures we receive messages that arrived while disconnected
debugPrint('📬 [Service] Syncing messages (CMD_SYNC_NEXT_MESSAGE)...');
await syncNextMessage();
debugPrint('✅ [Service] Message sync initiated');
}
/// Refresh device info (public method)
Future<void> refreshDeviceInfo() async {
await _sendDeviceQuery();
}
/// Get contacts from device
Future<void> getContacts() async {
await _commandSender.writeData(FrameBuilder.buildGetContacts());
}
/// Get a single contact by public key from device
///
/// This is more efficient than getContacts() when you only need to refresh
/// one specific contact (e.g., after receiving an advertisement).
///
/// The contact will be delivered via the onContactReceived callback.
Future<void> getContactByKey(Uint8List publicKey) async {
debugPrint('🔍 [BLE] Requesting single contact by key:');
debugPrint(
' Public key prefix: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
await _commandSender.writeData(FrameBuilder.buildGetContactByKey(publicKey));
}
/// Manually add or update a contact on the companion radio
Future<void> addOrUpdateContact(Contact contact) async {
debugPrint('📝 [BLE] Adding/updating contact on companion radio:');
debugPrint(' Name: ${contact.advName}');
debugPrint(
' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(' Type: ${contact.type} (${contact.type.value})');
await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact));
debugPrint('✅ [BLE] CMD_ADD_UPDATE_CONTACT sent');
}
/// Send text message to contact (DM)
Future<void> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Text message exceeds 160 character limit');
}
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(
FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey,
text: text,
textType: textType,
attempt: attempt,
),
);
}
/// Send flood-mode text message to channel
/// Track a sent channel message for echo detection
void trackSentChannelMessage(String messageId) {
debugPrint(
'🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId',
);
_responseHandler.trackSentMessage(messageId, null);
}
/// Send a text message to a channel (flood-mode broadcast)
///
/// Channel messages are ephemeral and use flood routing (no ACKs).
/// Use channel 0 for the default public channel.
///
/// Note: Uses fire-and-forget mode since channel messages don't return
/// delivery confirmation (they're broadcast to all nodes).
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
int textType = 0,
}) async {
if (text.length > 160) {
throw ArgumentError('Channel message too long (max ~160 characters)');
}
// Channel messages use fire-and-forget (no ACK expected)
// The firmware responds with RESP_CODE_OK but we don't wait for it
await _commandSender.writeData(
FrameBuilder.buildSendChannelTxtMsg(
channelIdx: channelIdx,
text: text,
textType: textType,
),
);
}
/// Request telemetry (GPS, battery) from contact
Future<void> requestTelemetry(
Uint8List contactPublicKey, {
bool zeroHop = false,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop),
);
}
/// Send binary request to contact
Future<void> sendBinaryRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
await _commandSender.writeData(
FrameBuilder.buildSendBinaryReq(
contactPublicKey: contactPublicKey,
requestData: requestData,
),
);
}
/// Get battery voltage and storage information
Future<void> getBatteryAndStorage() async {
await _commandSender.writeData(FrameBuilder.buildGetBatteryAndStorage());
}
/// Legacy method name for backward compatibility
@Deprecated('Use getBatteryAndStorage() instead')
Future<void> getBatteryVoltage() async {
await getBatteryAndStorage();
}
/// Sync next message from device queue
Future<void> syncNextMessage() async {
await _commandSender.writeData(FrameBuilder.buildSyncNextMessage());
}
/// Get device time from companion radio
Future<void> getDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildGetDeviceTime());
}
/// Set device time
Future<void> setDeviceTime() async {
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
}
/// Send self advertisement packet to mesh network
Future<void> sendSelfAdvert({bool floodMode = true}) async {
await _commandSender.writeData(
FrameBuilder.buildSendSelfAdvert(floodMode: floodMode),
);
}
/// Set advertised name
Future<void> setAdvertName(String name) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetAdvertName(name),
);
}
/// Set advertised latitude and longitude
Future<void> setAdvertLatLon({
required double latitude,
required double longitude,
}) async {
// This command updates device's advertised location
// Fire-and-forget - no ACK needed since actual broadcast happens via sendSelfAdvert
await _commandSender.writeData(
FrameBuilder.buildSetAdvertLatLon(
latitude: latitude,
longitude: longitude,
),
);
}
/// Set radio parameters
Future<void> setRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetRadioParams(
frequency: frequency,
bandwidth: bandwidth,
spreadingFactor: spreadingFactor,
codingRate: codingRate,
),
);
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetTxPower(powerDbm),
);
}
/// Set other parameters
Future<void> setOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) async {
await _commandSender.writeDataAndWaitForAck(
FrameBuilder.buildSetOtherParams(
manualAddContacts: manualAddContacts,
telemetryModes: telemetryModes,
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
),
);
}
/// Send login request to room or repeater
Future<void> loginToRoom({
required Uint8List roomPublicKey,
required String password,
}) async {
if (password.length > 15) {
throw ArgumentError('Password exceeds 15 character limit');
}
debugPrint('🔐 [BLE] Preparing login request:');
debugPrint(
' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
debugPrint(
' Password: ${"*" * password.length} (${password.length} chars)',
);
await _commandSender.writeData(
FrameBuilder.buildSendLogin(
roomPublicKey: roomPublicKey,
password: password,
),
);
}
/// Send status request to repeater or sensor node
Future<void> sendStatusRequest(Uint8List contactPublicKey) async {
debugPrint('📊 [BLE] Preparing status request:');
debugPrint(
' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildSendStatusReq(contactPublicKey),
);
}
/// Reset path for a contact - forces next message to flood and re-learn route
Future<void> resetPath(Uint8List contactPublicKey) async {
debugPrint('🔄 [BLE] Resetting path for contact:');
debugPrint(
' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildResetPath(contactPublicKey),
);
}
/// Remove a contact from the companion radio
Future<void> removeContact(Uint8List contactPublicKey) async {
debugPrint('🗑️ [BLE] Removing contact from companion radio:');
debugPrint(
' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
);
await _commandSender.writeData(
FrameBuilder.buildRemoveContact(contactPublicKey),
);
debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent');
}
/// Get information for a specific channel
Future<void> getChannel(int channelIdx) async {
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
}
/// Set the name and secret for a specific channel
///
/// The secret must be exactly 16 bytes (128-bit encryption key).
/// For the default public channel (channel 0), use [MeshCoreConstants.defaultPublicChannelSecret].
///
/// Note: Some firmware versions don't send ACK for SET_CHANNEL, so we use
/// fire-and-forget and then verify with GET_CHANNEL.
Future<void> setChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) async {
debugPrint('📻 [BLE] Setting channel:');
debugPrint(' Channel index: $channelIdx');
debugPrint(' Channel name: $channelName');
debugPrint(' Secret length: ${secret.length} bytes');
debugPrint(' Secret hex: ${secret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}');
// Send SET_CHANNEL command (fire-and-forget, no ACK expected)
final setChannelData = FrameBuilder.buildSetChannel(
channelIdx: channelIdx,
channelName: channelName,
secret: secret,
);
debugPrint(' SET_CHANNEL data (${setChannelData.length} bytes): ${setChannelData.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
await _commandSender.writeData(setChannelData);
debugPrint('✅ [BLE] CMD_SET_CHANNEL sent');
// Wait a bit for the device to process
await Future.delayed(const Duration(milliseconds: 200));
// Verify the channel was set by reading it back
debugPrint('🔍 [BLE] Verifying channel was set...');
await getChannel(channelIdx);
}
/// Delete a channel by clearing its slot
///
/// This removes the channel from the device by setting it to an empty name and zeroed secret.
/// The channel slot becomes available for reuse.
///
/// Note: Channel 0 (public channel) cannot be deleted.
Future<void> deleteChannel(int channelIdx) async {
if (channelIdx == 0) {
throw ArgumentError('Cannot delete channel 0 (public channel)');
}
debugPrint('🗑️ [BLE] Deleting channel $channelIdx...');
// Clear channel by setting empty name and zeroed secret
await setChannel(
channelIdx: channelIdx,
channelName: '',
secret: List.filled(16, 0),
);
debugPrint('✅ [BLE] Channel $channelIdx deleted');
}
/// Sync all channels from the device (channels 1-39)
/// Skips channel 0 (public channel) which is implicit and not stored on device
Future<void> syncAllChannels({int maxChannels = 40}) async {
debugPrint('📻 [Service] Syncing channels (1-${maxChannels - 1})...');
// Start from 1 to skip channel 0 (public channel)
// Channel 0 is implicit and handled separately via configurePublicChannel()
for (int i = 1; i < maxChannels; i++) {
await getChannel(i);
// Small delay to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 50));
}
debugPrint('✅ [Service] Channel sync complete');
}
/// Clear packet logs
void clearPacketLogs() {
_commandSender.clearPacketLogs();
_responseHandler.clearPacketLogs();
}
/// Reset packet counters
void resetCounters() {
_commandSender.resetCounter();
_responseHandler.resetCounter();
}
/// Start keepalive timer for iOS background mode
/// Periodically syncs messages to keep BLE connection alive and check for new messages
/// This serves dual purpose: prevents iOS from killing idle BLE connections AND
/// provides fallback message sync when push notifications (PUSH_CODE_MSG_WAITING) don't trigger
void _startKeepalive() {
_stopKeepalive(); // Stop any existing timer
debugPrint('🔄 [BLE] Starting keepalive timer (${_keepaliveInterval.inSeconds}s interval)');
_keepaliveTimer = Timer.periodic(_keepaliveInterval, (timer) async {
if (!isConnected) {
debugPrint('⚠️ [BLE] Keepalive: Not connected, stopping timer');
_stopKeepalive();
return;
}
try {
// Sync messages to keep connection alive AND check for new messages
// This is a fallback in case PUSH_CODE_MSG_WAITING doesn't fire
// If no messages waiting, device responds with RESP_CODE_NO_MORE_MSG
await syncNextMessage();
debugPrint('💚 [BLE] Keepalive: Connection maintained & messages synced');
} catch (e) {
debugPrint('⚠️ [BLE] Keepalive error: $e');
// Don't stop timer on error - iOS might throttle commands temporarily
}
});
}
/// Stop keepalive timer
void _stopKeepalive() {
if (_keepaliveTimer != null) {
debugPrint('🛑 [BLE] Stopping keepalive timer');
_keepaliveTimer?.cancel();
_keepaliveTimer = null;
}
}
/// Dispose resources
void dispose() {
_stopKeepalive(); // Clean up keepalive timer
_connectionManager.dispose();
_commandSender.dispose();
_responseHandler.dispose();
}
}

View File

@@ -0,0 +1,153 @@
/// MeshCore BLE and Protocol Constants
class MeshCoreConstants {
// Supported protocol version
static const int supportedCompanionProtocolVersion = 1;
// BLE Service and Characteristic UUIDs
static const String bleServiceUuid =
'6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
static const String bleCharacteristicRxUuid =
'6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; // Write
static const String bleCharacteristicTxUuid =
'6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; // Notify
// Command Codes (App -> Device)
static const int cmdAppStart = 1;
static const int cmdSendTxtMsg = 2;
static const int cmdSendChannelTxtMsg = 3;
static const int cmdGetContacts = 4;
static const int cmdGetDeviceTime = 5;
static const int cmdSetDeviceTime = 6;
static const int cmdSendSelfAdvert = 7;
static const int cmdSetAdvertName = 8;
static const int cmdAddUpdateContact = 9;
static const int cmdSyncNextMessage = 10;
static const int cmdSetRadioParams = 11;
static const int cmdSetTxPower = 12;
static const int cmdResetPath = 13;
static const int cmdSetAdvertLatLon = 14;
static const int cmdRemoveContact = 15;
static const int cmdShareContact = 16;
static const int cmdExportContact = 17;
static const int cmdImportContact = 18;
static const int cmdReboot = 19;
static const int cmdGetBatteryVoltage = 20;
static const int cmdSetTuningParams = 21;
static const int cmdDeviceQuery = 22;
static const int cmdExportPrivateKey = 23;
static const int cmdImportPrivateKey = 24;
static const int cmdSendRawData = 25;
static const int cmdSendLogin = 26;
static const int cmdSendStatusReq = 27;
static const int cmdGetContactByKey = 30;
static const int cmdGetChannel = 31;
static const int cmdSetChannel = 32;
static const int cmdSignStart = 33;
static const int cmdSignData = 34;
static const int cmdSignFinish = 35;
static const int cmdSendTracePath = 36;
static const int cmdSetOtherParams = 38;
static const int cmdSendTelemetryReq = 39;
static const int cmdSendBinaryReq = 50;
// Response Codes (Device -> App)
static const int respOk = 0;
static const int respErr = 1;
static const int respContactsStart = 2;
static const int respContact = 3;
static const int respEndOfContacts = 4;
static const int respSelfInfo = 5;
static const int respSent = 6;
static const int respContactMsgRecv = 7;
static const int respChannelMsgRecv = 8;
static const int respCurrTime = 9;
static const int respNoMoreMessages = 10;
static const int respExportContact = 11;
static const int respBatteryVoltage = 12;
static const int respDeviceInfo = 13;
static const int respPrivateKey = 14;
static const int respDisabled = 15;
static const int respChannelInfo = 18;
static const int respSignStart = 19;
static const int respSignature = 20;
static const int respCustomVars = 21;
static const int respAdvertPath = 22;
static const int respTuningParams = 21; // Same as respCustomVars per protocol
// Push Codes (Device -> App, unsolicited)
static const int pushAdvert = 0x80;
static const int pushPathUpdated = 0x81;
static const int pushSendConfirmed = 0x82;
static const int pushMsgWaiting = 0x83;
static const int pushRawData = 0x84;
static const int pushLoginSuccess = 0x85;
static const int pushLoginFail = 0x86;
static const int pushStatusResponse = 0x87;
static const int pushLogRxData = 0x88;
static const int pushTraceData = 0x89;
static const int pushNewAdvert = 0x8A;
static const int pushTelemetryResponse = 0x8B;
static const int pushBinaryResponse = 0x8C;
// Error Codes
static const int errUnsupportedCmd = 1;
static const int errNotFound = 2;
static const int errTableFull = 3;
static const int errBadState = 4;
static const int errFileIoError = 5;
static const int errIllegalArg = 6;
// Advert Types
static const int advTypeNone = 0;
static const int advTypeChat = 1;
static const int advTypeRepeater = 2;
static const int advTypeRoom = 3;
// Self Advert Types
static const int selfAdvertZeroHop = 0;
static const int selfAdvertFlood = 1;
// Text Types
static const int txtTypePlain = 0;
static const int txtTypeCliData = 1;
static const int txtTypeSignedPlain = 2;
// Binary Request Types
static const int binaryReqGetTelemetryData = 0x03;
static const int binaryReqGetAvgMinMax = 0x04;
static const int binaryReqGetAccessList = 0x05;
static const int binaryReqGetNeighbours = 0x06;
// Default Public Channel Secret (128-bit)
// This is the well-known pre-shared key for the public channel (channel 0)
// Hex: 8b3387e9c5cdea6ac9e5edbaa115cd72
// Base64: izOH6cXN6mrJ5e26oRXNcg==
// Source: https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md
static const List<int> defaultPublicChannelSecret = [
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
];
// Cayenne LPP Data Types
static const int lppDigitalInput = 0;
static const int lppDigitalOutput = 1;
static const int lppAnalogInput = 2;
static const int lppAnalogOutput = 3;
static const int lppIlluminanceSensor = 101;
static const int lppPresenceSensor = 102;
static const int lppTemperatureSensor = 103;
static const int lppHumiditySensor = 104;
static const int lppAccelerometer = 113;
static const int lppBarometer = 115;
static const int lppVoltageSensor = 116;
static const int lppGyrometer = 134;
static const int lppGps = 136;
// MTU and timing
static const int maxMtuSize = 512;
static const int defaultTimeout = 5000; // 5 seconds
static const int reconnectDelay = 2000; // 2 seconds
static const int telemetryUpdateInterval = 300000; // 5 minutes
MeshCoreConstants._(); // Private constructor to prevent instantiation
}

View File

@@ -0,0 +1,190 @@
import 'meshcore_constants.dart';
/// Maps MeshCore protocol opcodes to human-readable names
class MeshCoreOpcodeNames {
/// Get command name from opcode
static String getCommandName(int opcode) {
switch (opcode) {
case MeshCoreConstants.cmdAppStart:
return 'APP_START';
case MeshCoreConstants.cmdSendTxtMsg:
return 'SEND_TXT_MSG';
case MeshCoreConstants.cmdSendChannelTxtMsg:
return 'SEND_CHANNEL_TXT_MSG';
case MeshCoreConstants.cmdGetContacts:
return 'GET_CONTACTS';
case MeshCoreConstants.cmdGetDeviceTime:
return 'GET_DEVICE_TIME';
case MeshCoreConstants.cmdSetDeviceTime:
return 'SET_DEVICE_TIME';
case MeshCoreConstants.cmdSendSelfAdvert:
return 'SEND_SELF_ADVERT';
case MeshCoreConstants.cmdSetAdvertName:
return 'SET_ADVERT_NAME';
case MeshCoreConstants.cmdAddUpdateContact:
return 'ADD_UPDATE_CONTACT';
case MeshCoreConstants.cmdSyncNextMessage:
return 'SYNC_NEXT_MESSAGE';
case MeshCoreConstants.cmdSetRadioParams:
return 'SET_RADIO_PARAMS';
case MeshCoreConstants.cmdSetTxPower:
return 'SET_TX_POWER';
case MeshCoreConstants.cmdResetPath:
return 'RESET_PATH';
case MeshCoreConstants.cmdSetAdvertLatLon:
return 'SET_ADVERT_LAT_LON';
case MeshCoreConstants.cmdRemoveContact:
return 'REMOVE_CONTACT';
case MeshCoreConstants.cmdShareContact:
return 'SHARE_CONTACT';
case MeshCoreConstants.cmdExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.cmdImportContact:
return 'IMPORT_CONTACT';
case MeshCoreConstants.cmdReboot:
return 'REBOOT';
case MeshCoreConstants.cmdGetBatteryVoltage:
return 'GET_BATTERY_VOLTAGE';
case MeshCoreConstants.cmdSetTuningParams:
return 'SET_TUNING_PARAMS';
case MeshCoreConstants.cmdDeviceQuery:
return 'DEVICE_QUERY';
case MeshCoreConstants.cmdExportPrivateKey:
return 'EXPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdImportPrivateKey:
return 'IMPORT_PRIVATE_KEY';
case MeshCoreConstants.cmdSendRawData:
return 'SEND_RAW_DATA';
case MeshCoreConstants.cmdSendLogin:
return 'SEND_LOGIN';
case MeshCoreConstants.cmdSendStatusReq:
return 'SEND_STATUS_REQ';
case MeshCoreConstants.cmdGetContactByKey:
return 'GET_CONTACT_BY_KEY';
case MeshCoreConstants.cmdGetChannel:
return 'GET_CHANNEL';
case MeshCoreConstants.cmdSetChannel:
return 'SET_CHANNEL';
case MeshCoreConstants.cmdSignStart:
return 'SIGN_START';
case MeshCoreConstants.cmdSignData:
return 'SIGN_DATA';
case MeshCoreConstants.cmdSignFinish:
return 'SIGN_FINISH';
case MeshCoreConstants.cmdSendTracePath:
return 'SEND_TRACE_PATH';
case MeshCoreConstants.cmdSetOtherParams:
return 'SET_OTHER_PARAMS';
case MeshCoreConstants.cmdSendTelemetryReq:
return 'SEND_TELEMETRY_REQ';
case MeshCoreConstants.cmdSendBinaryReq:
return 'SEND_BINARY_REQ';
default:
return 'CMD_UNKNOWN';
}
}
/// Get response name from opcode
static String getResponseName(int opcode) {
switch (opcode) {
case MeshCoreConstants.respOk:
return 'OK';
case MeshCoreConstants.respErr:
return 'ERROR';
case MeshCoreConstants.respContactsStart:
return 'CONTACTS_START';
case MeshCoreConstants.respContact:
return 'CONTACT';
case MeshCoreConstants.respEndOfContacts:
return 'END_OF_CONTACTS';
case MeshCoreConstants.respSelfInfo:
return 'SELF_INFO';
case MeshCoreConstants.respSent:
return 'SENT';
case MeshCoreConstants.respContactMsgRecv:
return 'CONTACT_MSG_RECV';
case MeshCoreConstants.respChannelMsgRecv:
return 'CHANNEL_MSG_RECV';
case MeshCoreConstants.respCurrTime:
return 'CURR_TIME';
case MeshCoreConstants.respNoMoreMessages:
return 'NO_MORE_MESSAGES';
case MeshCoreConstants.respExportContact:
return 'EXPORT_CONTACT';
case MeshCoreConstants.respBatteryVoltage:
return 'BATTERY_VOLTAGE';
case MeshCoreConstants.respDeviceInfo:
return 'DEVICE_INFO';
case MeshCoreConstants.respPrivateKey:
return 'PRIVATE_KEY';
case MeshCoreConstants.respDisabled:
return 'DISABLED';
case MeshCoreConstants.respChannelInfo:
return 'CHANNEL_INFO';
case MeshCoreConstants.respSignStart:
return 'SIGN_START';
case MeshCoreConstants.respSignature:
return 'SIGNATURE';
default:
return 'RESP_UNKNOWN';
}
}
/// Get push notification name from opcode
static String getPushName(int opcode) {
switch (opcode) {
case MeshCoreConstants.pushAdvert:
return 'ADVERT';
case MeshCoreConstants.pushPathUpdated:
return 'PATH_UPDATED';
case MeshCoreConstants.pushSendConfirmed:
return 'SEND_CONFIRMED';
case MeshCoreConstants.pushMsgWaiting:
return 'MSG_WAITING';
case MeshCoreConstants.pushRawData:
return 'RAW_DATA';
case MeshCoreConstants.pushLoginSuccess:
return 'LOGIN_SUCCESS';
case MeshCoreConstants.pushLoginFail:
return 'LOGIN_FAIL';
case MeshCoreConstants.pushStatusResponse:
return 'STATUS_RESPONSE';
case MeshCoreConstants.pushLogRxData:
return 'LOG_RX_DATA';
case MeshCoreConstants.pushTraceData:
return 'TRACE_DATA';
case MeshCoreConstants.pushNewAdvert:
return 'NEW_ADVERT';
case MeshCoreConstants.pushTelemetryResponse:
return 'TELEMETRY_RESPONSE';
case MeshCoreConstants.pushBinaryResponse:
return 'BINARY_RESPONSE';
default:
return 'PUSH_UNKNOWN';
}
}
/// Get opcode name for any code (tries to determine type automatically)
static String getOpcodeName(int opcode, {bool isTx = false}) {
// If TX (sent to device), it's a command
if (isTx) {
return getCommandName(opcode);
}
// If RX (received from device), determine if it's a push or response
if (opcode >= 0x80) {
return getPushName(opcode);
} else {
return getResponseName(opcode);
}
}
/// Get full opcode description with code in hex
static String getOpcodeDescription(int opcode, {bool isTx = false}) {
final name = getOpcodeName(opcode, isTx: isTx);
final hex = '0x${opcode.toRadixString(16).padLeft(2, '0').toUpperCase()}';
return '$name ($hex)';
}
MeshCoreOpcodeNames._(); // Private constructor to prevent instantiation
}

View File

@@ -0,0 +1,71 @@
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';
/// Destination types
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
/// 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,
if (publicKey != null) 'publicKey': publicKey,
};
}
/// Save the selected destination
/// [type] - one of: 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 display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:
return 'Contact';
case destinationTypeRoom:
return 'Room';
default:
return 'Unknown';
}
}
}

View File

@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
Future<void> saveMessages(List<Message> messages) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
? jsonList.sublist(jsonList.length - _maxStoredMessages)
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving messages: $e');
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
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() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
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) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix != null
? base64Encode(message.senderPublicKeyPrefix!)
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsLat': message.sarGpsCoordinates?.latitude,
'sarGpsLon': message.sarGpsCoordinates?.longitude,
'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,
// Drawing message tracking
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
// 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(),
};
}
/// 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,
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 double, json['sarGpsLon'] as double)
: null,
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,
// Drawing message tracking
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] 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;
}
}
}

View File

@@ -0,0 +1,347 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:nsd/nsd.dart';
/// Discovered SSE server on the network
class DiscoveredServer {
final String ipAddress;
final int port;
final int responseTime; // in milliseconds
final String serverUrl;
DiscoveredServer({
required this.ipAddress,
required this.port,
required this.responseTime,
}) : serverUrl = 'http://$ipAddress:$port';
@override
String toString() {
return 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DiscoveredServer &&
other.ipAddress == ipAddress &&
other.port == port;
}
@override
int get hashCode => Object.hash(ipAddress, port);
}
/// Network Scanner Service
///
/// Discovers SSE servers on the local network using Bonjour/mDNS.
/// Falls back to port scanning (12929) if no services are discovered.
/// Uses parallel scanning (20 IPs at once) for fast discovery.
class NetworkScannerService {
static const int defaultPort = 12929;
static const String serviceType = '_meshcore-sse._tcp';
static const int parallelScans = 20;
static const Duration scanTimeout = Duration(seconds: 2);
static const Duration bonjourTimeout = Duration(seconds: 5);
Discovery? _activeDiscovery;
/// Callback for when a server is discovered
Function(DiscoveredServer)? onServerDiscovered;
/// Callback for scan progress updates
Function(int scanned, int total)? onProgressUpdate;
bool _isScanning = false;
bool get isScanning => _isScanning;
/// Cached discovered servers from the last scan
List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
/// Whether we have cached results from a previous scan
bool get hasCachedResults => _cachedServers.isNotEmpty;
/// Get all local IP addresses
Future<Set<String>> _getLocalIpAddresses() async {
final Set<String> localIps = {};
try {
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4) {
localIps.add(addr.address);
}
}
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Error getting local IPs: $e');
}
return localIps;
}
/// Get local network IP range to scan
Future<List<String>> _getLocalNetworkRange() async {
final List<String> ips = [];
try {
// Get all network interfaces
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
// Only scan IPv4 addresses that are not loopback
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
final ip = addr.address;
final parts = ip.split('.');
if (parts.length == 4) {
// Generate range for the same subnet (e.g., 192.168.1.1-254)
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
// Scan from .1 to .254 (skip .0 and .255)
for (int i = 1; i <= 254; i++) {
ips.add('$subnet.$i');
}
debugPrint('📡 [NetworkScanner] Will scan subnet: $subnet.0/24');
// Only scan first viable subnet
return ips;
}
}
}
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Error getting network interfaces: $e');
}
return ips;
}
/// Check if an IP has an SSE server running
Future<DiscoveredServer?> _checkServer(String ip, int port) async {
try {
final stopwatch = Stopwatch()..start();
final url = Uri.parse('http://$ip:$port/api/status');
final response = await http.get(url).timeout(scanTimeout);
stopwatch.stop();
if (response.statusCode == 200) {
debugPrint('✅ [NetworkScanner] Found server at $ip:$port (${stopwatch.elapsedMilliseconds}ms)');
return DiscoveredServer(
ipAddress: ip,
port: port,
responseTime: stopwatch.elapsedMilliseconds,
);
}
} on TimeoutException {
// Timeout - server not responding, ignore
} on SocketException {
// Connection refused - no server at this IP, ignore
} catch (e) {
// Other errors - ignore
debugPrint('⚠️ [NetworkScanner] Error checking $ip:$port - $e');
}
return null;
}
/// Discover servers using Bonjour/mDNS
Future<List<DiscoveredServer>> _discoverViaBonjourAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
try {
debugPrint('🔍 [NetworkScanner] Starting Bonjour discovery for $serviceType...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
// Start discovery with IP lookup
_activeDiscovery = await startDiscovery(
serviceType,
ipLookupType: IpLookupType.any,
);
// Wait for discovery to find services
await Future.delayed(bonjourTimeout);
// Process discovered services
final services = _activeDiscovery?.services ?? [];
debugPrint('📡 [NetworkScanner] Bonjour found ${services.length} services');
for (final service in services) {
if (service.addresses != null && service.addresses!.isNotEmpty) {
for (final address in service.addresses!) {
// Skip if this is a local IP address
if (localIps.contains(address.address)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${address.address}');
continue;
}
// Verify service is actually reachable
final result = await _checkServer(
address.address,
service.port ?? scanPort,
);
if (result != null) {
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
}
}
}
// Stop discovery
await stopDiscovery(_activeDiscovery!);
_activeDiscovery = null;
debugPrint('✅ [NetworkScanner] Bonjour discovery complete. Found ${discoveredServers.length} servers.');
} catch (e) {
debugPrint('⚠️ [NetworkScanner] Bonjour discovery failed: $e');
if (_activeDiscovery != null) {
try {
await stopDiscovery(_activeDiscovery!);
} catch (_) {}
_activeDiscovery = null;
}
}
return discoveredServers;
}
/// Scan the local network for SSE servers
/// First tries Bonjour/mDNS, then falls back to port scanning if nothing found
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) {
debugPrint('⚠️ [NetworkScanner] Scan already in progress');
return [];
}
_isScanning = true;
final scanPort = port ?? defaultPort;
List<DiscoveredServer> discoveredServers = [];
try {
// Try Bonjour/mDNS discovery first
discoveredServers = await _discoverViaBonjourAsync(port: scanPort);
// Fall back to port scanning if Bonjour found nothing
if (discoveredServers.isEmpty) {
debugPrint('🔍 [NetworkScanner] Bonjour found nothing, falling back to port scanning...');
discoveredServers = await _scanByPortAsync(port: scanPort);
}
// Cache the results
_cachedServers = discoveredServers;
} catch (e) {
debugPrint('❌ [NetworkScanner] Scan error: $e');
} finally {
_isScanning = false;
}
return discoveredServers;
}
/// Fallback port scanning method
Future<List<DiscoveredServer>> _scanByPortAsync({int? port}) async {
final scanPort = port ?? defaultPort;
final List<DiscoveredServer> discoveredServers = [];
try {
debugPrint('🔍 [NetworkScanner] Starting port scan on port $scanPort...');
// Get local IP addresses to filter out
final localIps = await _getLocalIpAddresses();
debugPrint('📍 [NetworkScanner] Local IPs: ${localIps.join(", ")}');
final ips = await _getLocalNetworkRange();
if (ips.isEmpty) {
debugPrint('⚠️ [NetworkScanner] No network interfaces found');
return [];
}
debugPrint('📊 [NetworkScanner] Scanning ${ips.length} IPs with $parallelScans parallel connections');
int scannedCount = 0;
// Scan in batches of 20 parallel connections
for (int i = 0; i < ips.length; i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
// Scan batch in parallel
final futures = batch.map((ip) => _checkServer(ip, scanPort)).toList();
final results = await Future.wait(futures);
// Collect discovered servers (excluding local IPs)
for (int j = 0; j < results.length; j++) {
final result = results[j];
if (result != null) {
// Skip if this is a local IP address
if (localIps.contains(result.ipAddress)) {
debugPrint('⏭️ [NetworkScanner] Skipping local IP: ${result.ipAddress}');
continue;
}
discoveredServers.add(result);
onServerDiscovered?.call(result);
}
}
scannedCount += batch.length;
onProgressUpdate?.call(scannedCount, ips.length);
}
debugPrint('✅ [NetworkScanner] Port scan complete. Found ${discoveredServers.length} servers.');
} catch (e) {
debugPrint('❌ [NetworkScanner] Port scan error: $e');
}
return discoveredServers;
}
/// Clear cached results (useful for forcing a fresh scan)
void clearCache() {
_cachedServers = [];
debugPrint('🗑️ [NetworkScanner] Cache cleared');
}
/// Stop ongoing scan
void stopScan() {
if (_isScanning) {
debugPrint('🛑 [NetworkScanner] Stopping scan...');
_isScanning = false;
}
}
/// Verify that a previously discovered server is still available
/// Returns true if server is reachable, false otherwise
Future<bool> verifyServer(DiscoveredServer server) async {
try {
debugPrint('🔍 [NetworkScanner] Verifying server at ${server.ipAddress}:${server.port}...');
final result = await _checkServer(server.ipAddress, server.port);
if (result != null) {
debugPrint('✅ [NetworkScanner] Server verified at ${server.ipAddress}:${server.port}');
return true;
} else {
debugPrint('❌ [NetworkScanner] Server no longer available at ${server.ipAddress}:${server.port}');
return false;
}
} catch (e) {
debugPrint('❌ [NetworkScanner] Server verification failed: $e');
return false;
}
}
}

View File

@@ -0,0 +1,621 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
/// Notification Service - manages urgent notifications for SAR messages
/// Provides critical alert functionality for SAR marker events
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool _permissionGranted = false;
// Notification IDs
static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000;
// Notification channels
static const String _urgentChannelId = 'sar_urgent';
static const String _urgentChannelName = 'SAR Urgent Alerts';
static const String _urgentChannelDescription =
'Critical alerts for SAR markers (found persons, fires, staging areas)';
static const String _messagesChannelId = 'messages';
static const String _messagesChannelName = 'Messages';
static const String _messagesChannelDescription =
'Notifications for incoming messages from contacts and channels';
static const String _updateChannelId = 'app_updates';
static const String _updateChannelName = 'App Updates';
static const String _updateChannelDescription =
'Notifications for available app updates';
/// Initialize notification service
Future<void> initialize() async {
if (_isInitialized) return;
try {
debugPrint('📬 [NotificationService] Initializing...');
// Initialize timezone data
tz.initializeTimeZones();
// Android initialization settings
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
// iOS initialization settings
final darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestCriticalPermission: true, // For urgent SAR notifications
);
// Combined initialization settings
final initSettings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
);
// Initialize plugin
await _notificationsPlugin.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
// Request permissions
await _requestPermissions();
// Create notification channels (Android)
await _createNotificationChannels();
_isInitialized = true;
debugPrint('✅ [NotificationService] Initialized successfully');
debugPrint(' Permission granted: $_permissionGranted');
} catch (e) {
debugPrint('❌ [NotificationService] Initialization error: $e');
}
}
/// Request notification permissions
Future<void> _requestPermissions() async {
try {
// iOS permissions
final iosPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
critical:
true, // Request critical alert permission for urgent SAR notifications
);
_permissionGranted = granted ?? false;
debugPrint(
'📱 [NotificationService] iOS permissions granted: $_permissionGranted',
);
return; // Exit early if on iOS
}
// Android 13+ permissions
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
_permissionGranted = granted ?? false;
debugPrint(
'🤖 [NotificationService] Android permissions granted: $_permissionGranted',
);
return; // Exit early if on Android
}
// If neither platform plugin is available, assume permissions are granted
// This handles older Android versions that don't require runtime permissions
_permissionGranted = true;
debugPrint(
'✅ [NotificationService] No platform plugin found, assuming permissions granted',
);
} catch (e) {
debugPrint('⚠️ [NotificationService] Error requesting permissions: $e');
}
}
/// Create notification channels for Android
Future<void> _createNotificationChannels() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin == null) return;
// Urgent SAR channel with maximum priority
const urgentChannel = AndroidNotificationChannel(
_urgentChannelId,
_urgentChannelName,
description: _urgentChannelDescription,
importance: Importance.max,
playSound: true,
enableVibration: true,
enableLights: true,
showBadge: true,
sound: RawResourceAndroidNotificationSound('notification'),
);
// Messages channel with high priority
const messagesChannel = AndroidNotificationChannel(
_messagesChannelId,
_messagesChannelName,
description: _messagesChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
// App updates channel with default priority
const updateChannel = AndroidNotificationChannel(
_updateChannelId,
_updateChannelName,
description: _updateChannelDescription,
importance: Importance.defaultImportance,
playSound: false,
enableVibration: false,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel);
debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
}
}
/// Callback for handling notification taps (set by main.dart)
void Function(String?)? onNotificationTapped;
/// Handle notification tap (foreground)
void _onNotificationResponse(NotificationResponse response) {
debugPrint(
'🔔 [NotificationService] Notification tapped: ${response.payload}',
);
// Call the registered callback if available
if (onNotificationTapped != null) {
onNotificationTapped!(response.payload);
}
}
/// Show urgent notification for SAR marker
Future<void> showSarNotification({
required SarMarkerType type,
required String senderName,
required String coordinates,
String? notes,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId =
_sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = _buildNotificationTitle(type, localizations);
final body = _buildNotificationBody(
type: type,
senderName: senderName,
coordinates: coordinates,
notes: notes,
localizations: localizations,
);
// Android notification details
final androidDetails = AndroidNotificationDetails(
_urgentChannelId,
_urgentChannelName,
channelDescription: _urgentChannelDescription,
importance: Importance.max,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
enableLights: true,
color: Color(_getNotificationColor(type)),
colorized: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
category: AndroidNotificationCategory.alarm, // High priority category
fullScreenIntent: true, // Show as full screen on some devices
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: _getSummaryText(type, localizations),
),
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
badgeNumber: 1,
threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT',
interruptionLevel:
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
payload: 'sar:${type.name}:$coordinates',
);
debugPrint('✅ [NotificationService] Showed SAR notification: $title');
debugPrint(' Type: ${type.displayName}');
debugPrint(' Sender: $senderName');
debugPrint(' Coordinates: $coordinates');
} catch (e) {
debugPrint('❌ [NotificationService] Error showing notification: $e');
}
}
/// Build notification title based on SAR marker type
String _buildNotificationTitle(
SarMarkerType type,
AppLocalizations? localizations,
) {
if (localizations == null) {
return '${type.emoji} ${type.displayName} Detected';
}
switch (type) {
case SarMarkerType.foundPerson:
return '${type.emoji} ${localizations.sarMarkerFoundPerson}';
case SarMarkerType.fire:
return '${type.emoji} ${localizations.sarMarkerFire}';
case SarMarkerType.stagingArea:
return '${type.emoji} ${localizations.sarMarkerStagingArea}';
case SarMarkerType.object:
return '${type.emoji} ${localizations.sarMarkerObject}';
case SarMarkerType.unknown:
return '${type.emoji} ${localizations.sarAlert}';
}
}
/// Build notification body with all details
String _buildNotificationBody({
required SarMarkerType type,
required String senderName,
required String coordinates,
String? notes,
AppLocalizations? localizations,
}) {
final buffer = StringBuffer();
// Sender
if (localizations != null) {
buffer.write('${localizations.from}: $senderName\n');
buffer.write('${localizations.coordinates}: $coordinates');
} else {
buffer.write('From: $senderName\n');
buffer.write('Coordinates: $coordinates');
}
// Optional notes
if (notes != null && notes.isNotEmpty) {
buffer.write('\n\n$notes');
}
return buffer.toString();
}
/// Get summary text for notification
String _getSummaryText(SarMarkerType type, AppLocalizations? localizations) {
if (localizations == null) {
return 'Tap to view on map';
}
return localizations.tapToViewOnMap;
}
/// Get notification color based on SAR marker type
int _getNotificationColor(SarMarkerType type) {
// Return ARGB color codes
switch (type) {
case SarMarkerType.foundPerson:
return 0xFF4CAF50; // Green
case SarMarkerType.fire:
return 0xFFF44336; // Red
case SarMarkerType.stagingArea:
return 0xFFFF9800; // Orange
case SarMarkerType.object:
return 0xFF2196F3; // Blue
case SarMarkerType.unknown:
return 0xFF9E9E9E; // Gray
}
}
/// Show notification for regular message (contact or channel)
Future<void> showMessageNotification({
required String senderName,
required String messageText,
required bool isChannelMessage,
String? channelName,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
final notificationId =
_messageNotificationId +
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
final body = messageText.length > 200
? '${messageText.substring(0, 200)}...'
: messageText;
// Android notification details
final androidDetails = AndroidNotificationDetails(
_messagesChannelId,
_messagesChannelName,
channelDescription: _messagesChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: senderName,
),
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: isChannelMessage
? 'channel_messages'
: 'direct_messages',
subtitle: senderName,
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
notificationId,
title,
body,
notificationDetails,
payload: 'message:${isChannelMessage ? "channel" : "contact"}',
);
debugPrint('✅ [NotificationService] Showed message notification');
debugPrint(' Sender: $senderName');
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing message notification: $e',
);
}
}
/// Cancel all notifications
Future<void> cancelAll() async {
try {
await _notificationsPlugin.cancelAll();
debugPrint('✅ [NotificationService] Cancelled all notifications');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notifications: $e');
}
}
/// Cancel specific notification
Future<void> cancel(int id) async {
try {
await _notificationsPlugin.cancel(id);
debugPrint('✅ [NotificationService] Cancelled notification: $id');
} catch (e) {
debugPrint('❌ [NotificationService] Error canceling notification: $e');
}
}
/// Check if notifications are enabled
Future<bool> areNotificationsEnabled() async {
try {
final androidPlugin = _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final enabled = await androidPlugin.areNotificationsEnabled();
return enabled ?? false;
}
// For iOS, assume enabled if permission was granted
return _permissionGranted;
} catch (e) {
debugPrint(
'⚠️ [NotificationService] Error checking notification status: $e',
);
return false;
}
}
/// Get pending notifications
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
try {
return await _notificationsPlugin.pendingNotificationRequests();
} catch (e) {
debugPrint(
'⚠️ [NotificationService] Error getting pending notifications: $e',
);
return [];
}
}
/// Show notification for available app update
Future<void> showUpdateNotification({
required String currentVersion,
required String latestVersion,
required String downloadUrl,
AppLocalizations? localizations,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return;
}
try {
// Build notification title and body
final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details
final androidDetails = AndroidNotificationDetails(
_updateChannelId,
_updateChannelName,
channelDescription: _updateChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
ticker: title,
playSound: false,
enableVibration: false,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
icon: '@mipmap/ic_launcher',
color: const Color(0xFF2196F3), // Blue
colorized: true,
category: AndroidNotificationCategory.recommendation,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: localizations?.downloadUpdate ?? 'Tap to download',
),
// Make notification ongoing so it doesn't get dismissed easily
ongoing: false,
autoCancel: true,
);
// iOS notification details
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
threadIdentifier: 'app_updates',
categoryIdentifier: 'APP_UPDATE',
subtitle: 'New version: $latestVersion',
);
// Combined notification details
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
// Show notification
await _notificationsPlugin.show(
_updateNotificationId,
title,
body,
notificationDetails,
payload: 'update:$downloadUrl',
);
debugPrint('✅ [NotificationService] Showed update notification');
debugPrint(' Current: $currentVersion');
debugPrint(' Latest: $latestVersion');
debugPrint(' Download URL: $downloadUrl');
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing update notification: $e',
);
}
}
}

View File

@@ -0,0 +1,292 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../buffer_writer.dart';
import '../meshcore_constants.dart';
/// Builds outgoing BLE frames for the MeshCore device
class FrameBuilder {
/// Build DeviceQuery command
static Uint8List buildDeviceQuery() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdDeviceQuery);
writer.writeByte(MeshCoreConstants.supportedCompanionProtocolVersion);
return writer.toBytes();
}
/// Build AppStart command
static Uint8List buildAppStart() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAppStart);
writer.writeByte(1); // appVer
writer.writeBytes(Uint8List(6)); // reserved
writer.writeString('MeshCore SAR'); // appName
return writer.toBytes();
}
/// Build GetContacts command
static Uint8List buildGetContacts() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContacts);
return writer.toBytes();
}
/// Build GetContactByKey command - retrieves a single contact by public key
static Uint8List buildGetContactByKey(Uint8List publicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetContactByKey); // 0x1E (30)
writer.writeBytes(publicKey); // 32 bytes
return writer.toBytes();
}
/// Build AddUpdateContact command
static Uint8List buildAddUpdateContact(Contact contact) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdAddUpdateContact); // 0x09
writer.writeBytes(contact.publicKey); // 32 bytes
writer.writeByte(contact.type.value); // ADV_TYPE_*
writer.writeByte(contact.flags); // flags
writer.writeInt8(contact.outPathLen); // path length (signed byte)
writer.writeBytes(contact.outPath); // 64 bytes
// Write name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(contact.advName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
writer.writeUInt32LE(contact.lastAdvert); // timestamp
writer.writeInt32LE(contact.advLat); // latitude * 1E6
writer.writeInt32LE(contact.advLon); // longitude * 1E6
return writer.toBytes();
}
/// Build SendTxtMsg command
static Uint8List buildSendTxtMsg({
required Uint8List contactPublicKey,
required String text,
int textType = 0,
int attempt = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTxtMsg); // 0x02
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(attempt); // 0-3
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeBytes(contactPublicKey.sublist(0, 6));
writer.writeString(text);
return writer.toBytes();
}
/// Build SendChannelTxtMsg command
static Uint8List buildSendChannelTxtMsg({
required int channelIdx,
required String text,
int textType = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendChannelTxtMsg); // 0x03
writer.writeByte(textType); // TXT_TYPE_*
writer.writeByte(channelIdx); // 0 for 'public' channel
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
writer.writeString(text);
return writer.toBytes();
}
/// Build SendTelemetryReq command
/// Requests telemetry (GPS, battery) from a contact
static Uint8List buildSendTelemetryReq(Uint8List contactPublicKey, {bool zeroHop = false}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendTelemetryReq);
writer.writeByte(zeroHop ? 0 : 255);
writer.writeByte(0); // reserved
writer.writeByte(0); // reserved
writer.writeBytes(contactPublicKey);
return writer.toBytes();
}
/// Build SendBinaryReq command
static Uint8List buildSendBinaryReq({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendBinaryReq); // 0x32 (50)
writer.writeBytes(contactPublicKey); // 32 bytes
writer.writeBytes(requestData); // request code + params
return writer.toBytes();
}
/// Build GetBatteryVoltage command
static Uint8List buildGetBatteryAndStorage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetBatteryVoltage);
return writer.toBytes();
}
/// Build SyncNextMessage command
static Uint8List buildSyncNextMessage() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSyncNextMessage);
return writer.toBytes();
}
/// Build GetDeviceTime command
static Uint8List buildGetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetDeviceTime);
return writer.toBytes();
}
/// Build SetDeviceTime command
static Uint8List buildSetDeviceTime() {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetDeviceTime);
writer.writeUInt32LE(DateTime.now().millisecondsSinceEpoch ~/ 1000);
return writer.toBytes();
}
/// Build SendSelfAdvert command
static Uint8List buildSendSelfAdvert({bool floodMode = true}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendSelfAdvert);
writer.writeByte(floodMode ? MeshCoreConstants.selfAdvertFlood : MeshCoreConstants.selfAdvertZeroHop);
return writer.toBytes();
}
/// Build SetAdvertName command
static Uint8List buildSetAdvertName(String name) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertName);
writer.writeString(name);
return writer.toBytes();
}
/// Build SetAdvertLatLon command
static Uint8List buildSetAdvertLatLon({
required double latitude,
required double longitude,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetAdvertLatLon);
writer.writeInt32LE((latitude * 1000000).round());
writer.writeInt32LE((longitude * 1000000).round());
return writer.toBytes();
}
/// Build SetRadioParams command
static Uint8List buildSetRadioParams({
required int frequency,
required int bandwidth,
required int spreadingFactor,
required int codingRate,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetRadioParams);
writer.writeUInt32LE(frequency);
writer.writeUInt16LE(bandwidth);
writer.writeByte(spreadingFactor);
writer.writeByte(codingRate);
return writer.toBytes();
}
/// Build SetTxPower command
static Uint8List buildSetTxPower(int powerDbm) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetTxPower);
writer.writeByte(powerDbm);
return writer.toBytes();
}
/// Build SetOtherParams command
static Uint8List buildSetOtherParams({
required int manualAddContacts,
required int telemetryModes,
required int advertLocationPolicy,
int multiAcks = 0,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetOtherParams);
writer.writeByte(manualAddContacts);
writer.writeByte(telemetryModes);
writer.writeByte(advertLocationPolicy);
writer.writeByte(multiAcks);
return writer.toBytes();
}
/// Build SendLogin command
static Uint8List buildSendLogin({
required Uint8List roomPublicKey,
required String password,
}) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendLogin); // 0x1A
writer.writeBytes(roomPublicKey); // 32 bytes
writer.writeString(password); // Max 15 bytes, null-terminated
return writer.toBytes();
}
/// Build SendStatusReq command
static Uint8List buildSendStatusReq(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSendStatusReq); // 0x1B
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build ResetPath command - clears learned path for a contact
static Uint8List buildResetPath(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdResetPath); // 0x0D (13)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build RemoveContact command - removes a contact from the device
static Uint8List buildRemoveContact(Uint8List contactPublicKey) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdRemoveContact); // 0x0F (15)
writer.writeBytes(contactPublicKey); // 32 bytes
return writer.toBytes();
}
/// Build GetChannel command - retrieves information for a specific channel
static Uint8List buildGetChannel(int channelIdx) {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
writer.writeByte(channelIdx); // 0-39 typically
return writer.toBytes();
}
/// Build SetChannel command - sets the name and secret for a specific channel
///
/// Format: [cmd(1)][channel_idx(1)][name(32)][secret(16)]
/// Secret must be exactly 16 bytes (128-bit key)
static Uint8List buildSetChannel({
required int channelIdx,
required String channelName,
required List<int> secret,
}) {
if (secret.length != 16) {
throw ArgumentError('Channel secret must be exactly 16 bytes (got ${secret.length})');
}
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
writer.writeByte(channelIdx); // 0-39 typically
// Write channel name as null-terminated string in 32-byte field
final nameBytes = Uint8List(32);
final encoded = utf8.encode(channelName);
final copyLen = encoded.length > 31 ? 31 : encoded.length;
nameBytes.setRange(0, copyLen, encoded);
writer.writeBytes(nameBytes);
// Write 16-byte secret
writer.writeBytes(Uint8List.fromList(secret));
return writer.toBytes();
}
}

View File

@@ -0,0 +1,436 @@
import 'dart:convert';
import 'dart:typed_data';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../buffer_reader.dart';
import '../meshcore_constants.dart';
/// Parses incoming BLE frames from the MeshCore device
class FrameParser {
/// Parse ContactsStart response
static int parseContactsStart(BufferReader reader) {
return reader.readUInt32LE();
}
/// Parse Contact response
static Contact parseContact(BufferReader reader) {
final publicKey = reader.readBytes(32);
final typeByte = reader.readByte();
final type = ContactType.fromValue(typeByte);
final flags = reader.readByte();
final outPathLen = reader.readInt8();
final outPath = reader.readBytes(64);
final advName = reader.readCString(32);
final lastAdvert = reader.readUInt32LE();
final advLat = reader.readInt32LE();
final advLon = reader.readInt32LE();
final lastMod = reader.readUInt32LE();
return Contact(
publicKey: publicKey,
type: type,
flags: flags,
outPathLen: outPathLen,
outPath: outPath,
advName: advName,
lastAdvert: lastAdvert,
advLat: advLat,
advLon: advLon,
lastMod: lastMod,
);
}
/// Parse Sent confirmation response
static Map<String, dynamic> parseSentConfirmation(BufferReader reader) {
if (reader.remainingBytesCount >= 9) {
final sendType = reader.readByte();
final isFloodMode = sendType == 1;
final expectedAckOrTagBytes = reader.readBytes(4);
final expectedAckTag = ByteData.sublistView(Uint8List.fromList(expectedAckOrTagBytes))
.getUint32(0, Endian.little);
final suggestedTimeout = reader.readUInt32LE();
return {
'expectedAckTag': expectedAckTag,
'suggestedTimeout': suggestedTimeout,
'isFloodMode': isFloodMode,
};
}
return {};
}
/// Parse ContactMessage response
static Message parseContactMessage(BufferReader reader) {
final pubKeyPrefix = reader.readBytes(6);
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
// Signed message format: [4-byte sender prefix][UTF-8 text]
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_${pubKeyPrefix.map((b) => b.toRadixString(16)).join()}',
messageType: MessageType.contact,
senderPublicKeyPrefix: pubKeyPrefix,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: text,
receivedAt: DateTime.now(),
);
}
/// Parse ChannelMessage response
static Message parseChannelMessage(BufferReader reader) {
final channelIdx = reader.readByte(); // unsigned 0-255, not signed
final pathLen = reader.readByte();
final txtTypeByte = reader.readByte();
final txtType = MessageTextType.fromValue(txtTypeByte);
final senderTimestamp = reader.readUInt32LE();
String text;
if (txtType == MessageTextType.signedPlain) {
if (reader.remainingBytesCount >= 4) {
reader.readBytes(4); // Skip extra sender prefix
text = reader.hasRemaining ? reader.readString() : '';
} else {
text = reader.readString();
}
} else {
text = reader.readString();
}
// Parse sender name from channel message format: "<sender_name>: <actual_message>"
String? senderName;
String actualMessage = text;
if (text.contains(': ')) {
final colonIndex = text.indexOf(': ');
senderName = text.substring(0, colonIndex);
actualMessage = text.substring(colonIndex + 2); // Skip ": "
}
return Message(
id: '${DateTime.now().millisecondsSinceEpoch}_ch$channelIdx',
messageType: MessageType.channel,
channelIdx: channelIdx,
pathLen: pathLen,
textType: txtType,
senderTimestamp: senderTimestamp,
text: actualMessage, // Store the actual message without sender prefix
senderName: senderName, // Store extracted sender name
receivedAt: DateTime.now(),
);
}
/// Parse TelemetryResponse push
static Map<String, dynamic> parseTelemetryResponse(BufferReader reader) {
reader.readByte(); // reserved
final pubKeyPrefix = reader.readBytes(6);
final lppSensorData = reader.readRemainingBytes();
return {
'publicKeyPrefix': pubKeyPrefix,
'lppSensorData': lppSensorData,
};
}
/// Parse BinaryResponse push
static Map<String, dynamic> parseBinaryResponse(BufferReader reader) {
reader.readByte(); // reserved
final tag = reader.readUInt32LE();
final responseData = reader.readRemainingBytes();
return {
'publicKeyPrefix': Uint8List(6), // Empty prefix
'tag': tag,
'responseData': responseData,
};
}
/// Parse DeviceInfo response
static Map<String, dynamic> parseDeviceInfo(BufferReader reader) {
if (reader.remainingBytesCount < 1) {
return {};
}
final firmwareVersion = reader.readByte();
int? maxContacts;
int? maxChannels;
int? blePin;
if (reader.remainingBytesCount >= 6) {
final maxContactsDiv2 = reader.readByte();
maxContacts = maxContactsDiv2 * 2;
maxChannels = reader.readByte();
blePin = reader.readUInt32LE();
}
String? firmwareBuildDate;
if (reader.remainingBytesCount >= 12) {
final buildDateBytes = reader.readBytes(12);
firmwareBuildDate =
String.fromCharCodes(buildDateBytes.takeWhile((b) => b != 0));
}
String? manufacturerModel;
if (reader.remainingBytesCount >= 40) {
final modelBytes = reader.readBytes(40);
manufacturerModel =
String.fromCharCodes(modelBytes.takeWhile((b) => b != 0));
}
String? semanticVersion;
if (reader.remainingBytesCount >= 20) {
final versionBytes = reader.readBytes(20);
semanticVersion =
String.fromCharCodes(versionBytes.takeWhile((b) => b != 0));
}
return {
'firmwareVersion': firmwareVersion,
'maxContacts': maxContacts,
'maxChannels': maxChannels,
'blePin': blePin,
'firmwareBuildDate': firmwareBuildDate,
'manufacturerModel': manufacturerModel,
'semanticVersion': semanticVersion,
};
}
/// Parse SelfInfo response
static Map<String, dynamic> parseSelfInfo(BufferReader reader) {
if (reader.remainingBytesCount < 54) {
reader.readRemainingBytes();
return {};
}
final deviceType = reader.readByte();
final txPower = reader.readByte();
final maxTxPower = reader.readByte();
final publicKey = reader.readBytes(32);
final advLatBytes = reader.readBytes(4);
final advLat = ByteData.sublistView(Uint8List.fromList(advLatBytes))
.getInt32(0, Endian.little);
final advLonBytes = reader.readBytes(4);
final advLon = ByteData.sublistView(Uint8List.fromList(advLonBytes))
.getInt32(0, Endian.little);
reader.readByte(); // multiAcks (reserved for future use)
reader.readByte(); // advertLocPolicy (reserved for future use)
reader.readByte(); // telemetryModes (reserved for future use)
final manualAddContacts = reader.readByte();
final radioFreqBytes = reader.readBytes(4);
final radioFreq = ByteData.sublistView(Uint8List.fromList(radioFreqBytes))
.getUint32(0, Endian.little);
final radioBwBytes = reader.readBytes(4);
final radioBw = ByteData.sublistView(Uint8List.fromList(radioBwBytes))
.getUint32(0, Endian.little);
final radioSf = reader.readByte();
final radioCr = reader.readByte();
String? selfName;
if (reader.hasRemaining) {
final nameBytes = reader.readRemainingBytes();
selfName = utf8.decode(nameBytes.takeWhile((b) => b != 0).toList());
}
return {
'deviceType': deviceType,
'txPower': txPower,
'maxTxPower': maxTxPower,
'publicKey': publicKey,
'advLat': advLat,
'advLon': advLon,
'manualAddContacts': manualAddContacts == 1,
'radioFreq': radioFreq,
'radioBw': radioBw,
'radioSf': radioSf,
'radioCr': radioCr,
'selfName': selfName,
};
}
/// Parse Advert push
static Uint8List? parseAdvert(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse PathUpdated push
static Uint8List? parsePathUpdated(BufferReader reader) {
if (reader.remainingBytesCount >= 32) {
return reader.readBytes(32);
}
return null;
}
/// Parse SendConfirmed push
static Map<String, dynamic> parseSendConfirmed(BufferReader reader) {
if (reader.remainingBytesCount >= 8) {
final ackCodeBytes = reader.readBytes(4);
final ackCode = ByteData.sublistView(Uint8List.fromList(ackCodeBytes))
.getUint32(0, Endian.little);
final roundTripTime = reader.readUInt32LE();
return {
'ackCode': ackCode,
'roundTripTime': roundTripTime,
};
}
return {};
}
/// Parse LoginSuccess push
static Map<String, dynamic> parseLoginSuccess(BufferReader reader) {
if (reader.remainingBytesCount >= 11) {
final permissions = reader.readByte();
final isAdmin = (permissions & 0x01) != 0;
final publicKeyPrefix = reader.readBytes(6);
final tag = reader.readInt32LE();
int? newPermissions;
if (reader.hasRemaining) {
newPermissions = reader.readByte();
}
return {
'publicKeyPrefix': publicKeyPrefix,
'permissions': permissions,
'isAdmin': isAdmin,
'tag': tag,
'newPermissions': newPermissions,
};
}
return {};
}
/// Parse LoginFail push
static Uint8List? parseLoginFail(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
return reader.readBytes(6);
}
return null;
}
/// Parse StatusResponse push
static Map<String, dynamic> parseStatusResponse(BufferReader reader) {
if (reader.remainingBytesCount >= 7) {
reader.readByte(); // reserved
final publicKeyPrefix = reader.readBytes(6);
final statusData = reader.readRemainingBytes();
return {
'publicKeyPrefix': publicKeyPrefix,
'statusData': statusData,
};
}
return {};
}
/// Parse CurrentTime response
static int? parseCurrentTime(BufferReader reader) {
if (reader.remainingBytesCount >= 4) {
return reader.readUInt32LE();
}
return null;
}
/// Parse BatteryAndStorage response
static Map<String, dynamic> parseBatteryAndStorage(BufferReader reader) {
if (reader.remainingBytesCount >= 2) {
final millivolts = reader.readUInt16LE();
int? usedKb;
int? totalKb;
if (reader.remainingBytesCount >= 8) {
usedKb = reader.readUInt32LE();
totalKb = reader.readUInt32LE();
} else if (reader.remainingBytesCount >= 4) {
usedKb = reader.readUInt32LE();
}
return {
'millivolts': millivolts,
'usedKb': usedKb,
'totalKb': totalKb,
};
}
return {};
}
/// Parse Error response
static int? parseError(BufferReader reader) {
if (reader.hasRemaining) {
return reader.readByte();
}
return null;
}
/// Parse ChannelInfo response
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
// Format: [channel_idx(1)][name(32)][secret(16)][flags(1)?]
// Minimum: 1 + 32 + 16 = 49 bytes (flags is optional)
if (reader.remainingBytesCount < 49) {
return {};
}
final channelIdx = reader.readByte();
final channelName = reader.readCString(32);
final secret = reader.readBytes(16);
// Flags field is optional (some firmware versions don't include it)
int? flags;
if (reader.remainingBytesCount >= 1) {
flags = reader.readByte();
}
return {
'channelIdx': channelIdx,
'channelName': channelName,
'secret': secret,
'flags': flags,
};
}
/// Get error message from error code
static String getErrorMessage(int errorCode) {
switch (errorCode) {
case MeshCoreConstants.errUnsupportedCmd:
return 'Unsupported command';
case MeshCoreConstants.errNotFound:
return 'Not found';
case MeshCoreConstants.errTableFull:
return 'Table full';
case MeshCoreConstants.errBadState:
return 'Bad state';
case MeshCoreConstants.errFileIoError:
return 'File I/O error';
case MeshCoreConstants.errIllegalArg:
return 'Illegal argument';
default:
return 'Error code: $errorCode';
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,625 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' as io_client;
import '../models/message.dart';
import '../models/contact.dart';
import 'package:latlong2/latlong.dart';
/// SSE Client Service
///
/// Connects to a remote SSE server to receive messages and contacts in real-time.
/// This enables multiple app instances to share a single MeshCore BLE device
/// without direct BLE connections.
class SseClientService {
String? _serverUrl;
String? _authToken;
http.Client? _httpClient;
StreamSubscription? _messageSubscription;
StreamSubscription? _contactSubscription;
bool _isConnected = false;
bool _isConnecting = false;
bool _hasConnectedBefore = false; // Track if we've ever successfully connected
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 10;
static const Duration _reconnectDelay = Duration(seconds: 5);
/// Callback for when a message is received
Function(Message)? onMessageReceived;
/// Callback for when a contact is received
Function(Contact)? onContactReceived;
/// Callback for connection state changes
Function(bool isConnected)? onConnectionStateChanged;
/// Callback for errors
Function(String error)? onError;
/// Check if client is connected
bool get isConnected => _isConnected;
/// Check if client is currently connecting
bool get isConnecting => _isConnecting;
/// Get current reconnection attempt number
int get reconnectionAttempts => _reconnectAttempts;
/// Get maximum reconnection attempts
int get maxReconnectionAttempts => _maxReconnectAttempts;
/// Get server URL
String? get serverUrl => _serverUrl;
/// Connect to SSE server
Future<void> connect({
required String serverUrl,
String? authToken,
}) async {
if (_isConnected) {
debugPrint('⚠️ [SseClient] Already connected');
return;
}
_serverUrl = serverUrl;
_authToken = authToken;
_isConnecting = true;
debugPrint('🔌 [SseClient] Connecting to $serverUrl (attempt ${_reconnectAttempts + 1}/$_maxReconnectAttempts)');
try {
// Create a new HTTP client with custom configuration for SSE streaming
// Using IOClient with custom HttpClient for better control over connection settings
final ioHttpClient = io.HttpClient();
ioHttpClient.connectionTimeout = const Duration(seconds: 10);
ioHttpClient.idleTimeout = const Duration(hours: 1); // Keep SSE connections alive
_httpClient = io_client.IOClient(ioHttpClient);
// Test server availability
await _checkServerStatus();
// Fetch initial message history
await _fetchMessageHistory();
// Fetch initial contact list
await _fetchContacts();
// Subscribe to SSE streams
debugPrint('🔗 [SseClient] Subscribing to message stream...');
debugPrint('🔗 [SseClient] Using HTTP client type: ${_httpClient.runtimeType}');
await _subscribeToMessages();
debugPrint('🔗 [SseClient] Subscribing to contact stream...');
await _subscribeToContacts();
debugPrint('🔗 [SseClient] All subscriptions complete');
_isConnected = true;
_isConnecting = false;
_hasConnectedBefore = true; // Mark that we've successfully connected
_reconnectAttempts = 0;
debugPrint('🔔 [SseClient] Calling onConnectionStateChanged(true)');
onConnectionStateChanged?.call(true);
// Start heartbeat to detect connection loss
_startHeartbeat();
debugPrint('✅ [SseClient] Connected successfully');
} catch (e) {
_isConnecting = false;
_httpClient?.close();
_httpClient = null;
debugPrint('❌ [SseClient] Connection failed: $e');
onError?.call('Connection failed: $e');
// Only auto-reconnect if we've successfully connected before
// Initial connection failures should be handled by the user
if (_hasConnectedBefore) {
_scheduleReconnect();
}
}
}
/// Disconnect from SSE server
Future<void> disconnect() async {
debugPrint('🔌 [SseClient] Disconnecting...');
_isConnected = false;
_isConnecting = false;
_hasConnectedBefore = false; // Reset on manual disconnect
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
await _messageSubscription?.cancel();
await _contactSubscription?.cancel();
_httpClient?.close();
_serverUrl = null;
_authToken = null;
_httpClient = null;
onConnectionStateChanged?.call(false);
debugPrint('✅ [SseClient] Disconnected');
}
/// Check server status
Future<void> _checkServerStatus() async {
final url = Uri.parse('$_serverUrl/api/status');
try {
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 5),
);
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
}
final data = jsonDecode(response.body);
debugPrint('📊 [SseClient] Server status: ${data['status']}');
debugPrint(' Connected clients: ${data['connectedClients']}');
debugPrint(' Messages: ${data['messageCount']}');
debugPrint(' Contacts: ${data['contactCount']}');
} catch (e) {
// Wrap the error with more user-friendly message
throw Exception(_formatConnectionError(e));
}
}
/// Format connection error to be more user-friendly
String _formatConnectionError(dynamic error) {
final errorStr = error.toString();
// Extract the actual server URL being connected to
final serverUri = Uri.tryParse(_serverUrl ?? '');
final host = serverUri?.host ?? 'unknown';
final port = serverUri?.port ?? 0;
if (errorStr.contains('Connection refused')) {
return 'Server not available at $host:$port. The server may be offline or not running.';
} else if (errorStr.contains('TimeoutException') || errorStr.contains('timed out')) {
return 'Connection to $host:$port timed out. Check your network connection.';
} else if (errorStr.contains('SocketException')) {
return 'Network error connecting to $host:$port. Check your network connection.';
} else if (errorStr.contains('Failed host lookup')) {
return 'Could not resolve hostname: $host';
}
// Return the original error if we can't make it more user-friendly
return errorStr;
}
/// Fetch message history on connect
Future<void> _fetchMessageHistory() async {
try {
final url = Uri.parse('$_serverUrl/api/messages/history');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch message history: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final messages = data['messages'] as List;
debugPrint('📥 [SseClient] Received ${messages.length} messages from history');
for (final msgJson in messages) {
try {
final message = _messageFromJson(msgJson);
onMessageReceived?.call(message);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse message: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching message history: $e');
// Don't throw - continue with connection even if history fetch fails
}
}
/// Fetch contacts on connect
Future<void> _fetchContacts() async {
try {
final url = Uri.parse('$_serverUrl/api/contacts');
final response = await http.get(url, headers: _getHeaders()).timeout(
const Duration(seconds: 10),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch contacts: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final contacts = data['contacts'] as List;
debugPrint('📥 [SseClient] Received ${contacts.length} contacts');
for (final contactJson in contacts) {
try {
final contact = _contactFromJson(contactJson);
onContactReceived?.call(contact);
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse contact: $e');
}
}
} catch (e) {
debugPrint('❌ [SseClient] Error fetching contacts: $e');
// Don't throw - continue with connection even if contacts fetch fails
}
}
/// Subscribe to SSE message stream
Future<void> _subscribeToMessages() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating message stream request...');
final url = Uri.parse('$_serverUrl/sse/messages');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending message stream request to $url');
debugPrint('📡 [SseClient] Request headers: ${request.headers}');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('❌ [SseClient] Timeout waiting for response headers');
throw TimeoutException('Message stream connection timed out after 10 seconds');
},
);
debugPrint('📡 [SseClient] Received response with status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Response headers: ${streamedResponse.headers}');
debugPrint('📡 [SseClient] Response content length: ${streamedResponse.contentLength}');
debugPrint('📡 [SseClient] Response is redirect: ${streamedResponse.isRedirect}');
if (streamedResponse.statusCode != 200) {
throw Exception('SSE messages subscription failed: ${streamedResponse.statusCode}');
}
debugPrint('📡 [SseClient] Message stream response received, status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Setting up stream listener...');
_messageSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received line: "$line"');
_handleSseLine(line, 'message');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Message stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Message stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Message stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to message stream: $e');
rethrow;
}
}
/// Subscribe to SSE contact stream
Future<void> _subscribeToContacts() async {
try {
if (_httpClient == null) {
throw Exception('HTTP client not initialized');
}
debugPrint('📡 [SseClient] Creating contact stream request...');
final url = Uri.parse('$_serverUrl/sse/contacts');
final request = http.Request('GET', url);
request.headers.addAll(_getHeaders());
request.headers['Accept'] = 'text/event-stream';
request.headers['Cache-Control'] = 'no-cache';
debugPrint('📡 [SseClient] Sending contact stream request to $url');
final streamedResponse = await _httpClient!.send(request).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Contact stream connection timed out after 10 seconds');
},
);
if (streamedResponse.statusCode != 200) {
throw Exception('SSE contacts subscription failed: ${streamedResponse.statusCode}');
}
debugPrint('📡 [SseClient] Contact stream response received, status: ${streamedResponse.statusCode}');
debugPrint('📡 [SseClient] Setting up contact stream listener...');
_contactSubscription = streamedResponse.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
(line) {
debugPrint('📨 [SseClient] Received contact line: "$line"');
_handleSseLine(line, 'contact');
},
onError: (error, stackTrace) {
debugPrint('❌ [SseClient] Contact stream error: $error');
debugPrint(' Stack trace: $stackTrace');
_handleDisconnect();
},
onDone: () {
debugPrint('⚠️ [SseClient] Contact stream closed (onDone called)');
_handleDisconnect();
},
cancelOnError: false,
);
debugPrint('✅ [SseClient] Contact stream listener set up successfully');
} catch (e) {
debugPrint('❌ [SseClient] Error subscribing to contact stream: $e');
rethrow;
}
}
/// Handle SSE line
String _eventType = '';
void _handleSseLine(String line, String streamType) {
if (line.isEmpty) {
// Event complete, reset
_eventType = '';
return;
}
if (line.startsWith('event:')) {
_eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
final jsonData = line.substring(5).trim();
try {
final data = jsonDecode(jsonData) as Map<String, dynamic>;
if (streamType == 'message' && _eventType == 'message') {
final message = _messageFromJson(data);
onMessageReceived?.call(message);
} else if (streamType == 'contact' && _eventType == 'contact') {
final contact = _contactFromJson(data);
onContactReceived?.call(contact);
}
} catch (e) {
debugPrint('⚠️ [SseClient] Failed to parse SSE data: $e');
}
}
}
/// Handle disconnect
void _handleDisconnect() {
if (!_isConnected) return;
_isConnected = false;
onConnectionStateChanged?.call(false);
_scheduleReconnect();
}
/// Schedule reconnection attempt
void _scheduleReconnect() {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('❌ [SseClient] Max reconnection attempts reached');
onError?.call('Max reconnection attempts reached');
return;
}
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 [SseClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
if (_serverUrl != null) {
connect(serverUrl: _serverUrl!, authToken: _authToken);
}
});
}
/// Start heartbeat to detect connection loss
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
try {
await _checkServerStatus();
} catch (e) {
debugPrint('⚠️ [SseClient] Heartbeat failed: $e');
_handleDisconnect();
}
});
}
/// Send message to server
Future<bool> sendMessage({
required String recipientPublicKey,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'recipientPublicKey': recipientPublicKey,
'text': text,
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send message failed: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['success'] as bool? ?? false;
} catch (e) {
debugPrint('❌ [SseClient] Error sending message: $e');
rethrow;
}
}
/// Send channel message to server
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
}) async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/messages/channel');
final response = await http.post(
url,
headers: {
..._getHeaders(),
'Content-Type': 'application/json',
},
body: jsonEncode({
'channelIdx': channelIdx,
'text': text,
}),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Send channel message failed: ${response.statusCode}');
}
} catch (e) {
debugPrint('❌ [SseClient] Error sending channel message: $e');
rethrow;
}
}
/// Request contact sync
Future<void> syncContacts() async {
if (!_isConnected || _serverUrl == null) {
throw Exception('Not connected to server');
}
try {
final url = Uri.parse('$_serverUrl/api/contacts/sync');
final response = await http.post(
url,
headers: _getHeaders(),
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Contact sync failed: ${response.statusCode}');
}
debugPrint('✅ [SseClient] Contact sync requested');
} catch (e) {
debugPrint('❌ [SseClient] Error syncing contacts: $e');
rethrow;
}
}
/// Get headers for HTTP requests
Map<String, String> _getHeaders() {
final headers = <String, String>{};
if (_authToken != null) {
headers['Authorization'] = 'Bearer $_authToken';
}
return headers;
}
/// Convert JSON to Message
Message _messageFromJson(Map<String, dynamic> json) {
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((json['senderPublicKeyPrefix'] as List).cast<int>())
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
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['sarGpsCoordinates'] != null
? LatLng(
(json['sarGpsCoordinates']['latitude'] as num).toDouble(),
(json['sarGpsCoordinates']['longitude'] as num).toDouble(),
)
: null,
sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?,
receivedAt: DateTime.parse(json['receivedAt'] as String),
senderName: json['senderName'] as String?,
deliveryStatus: MessageDeliveryStatus.values.firstWhere(
(e) => e.name == json['deliveryStatus'],
orElse: () => MessageDeliveryStatus.received,
),
expectedAckTag: json['expectedAckTag'] as int?,
suggestedTimeoutMs: json['suggestedTimeoutMs'] as int?,
roundTripTimeMs: json['roundTripTimeMs'] as int?,
deliveredAt: json['deliveredAt'] != null
? DateTime.parse(json['deliveredAt'] as String)
: null,
recipientPublicKey: json['recipientPublicKey'] != null
? Uint8List.fromList((json['recipientPublicKey'] as List).cast<int>())
: null,
retryAttempt: json['retryAttempt'] as int? ?? 0,
lastRetryAt: json['lastRetryAt'] != null
? DateTime.parse(json['lastRetryAt'] as String)
: null,
usedFloodFallback: json['usedFloodFallback'] as bool? ?? false,
isRead: json['isRead'] as bool? ?? false,
echoCount: json['echoCount'] as int? ?? 0,
firstEchoAt: json['firstEchoAt'] != null
? DateTime.parse(json['firstEchoAt'] as String)
: null,
isDrawing: json['isDrawing'] as bool? ?? false,
drawingId: json['drawingId'] as String?,
);
}
/// Convert JSON to Contact
Contact _contactFromJson(Map<String, dynamic> json) {
return Contact(
publicKey: Uint8List.fromList((json['publicKey'] as List).cast<int>()),
type: ContactType.fromValue(json['type'] as int),
flags: json['flags'] as int,
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList((json['outPath'] as List).cast<int>()),
advName: json['advName'] as String,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
lastMod: json['lastMod'] as int,
);
}
/// Dispose resources
void dispose() {
disconnect();
}
}

View File

@@ -0,0 +1,744 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'package:nsd/nsd.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/sse_server_config.dart';
import 'network_scanner_service.dart';
/// SSE Server Service
///
/// Provides a web server with SSE (Server-Sent Events) endpoints for
/// real-time message and contact updates, enabling multiple app instances
/// to share a single MeshCore BLE device.
///
/// Endpoints:
/// - GET /sse/messages - SSE stream for message updates
/// - GET /sse/contacts - SSE stream for contact updates
/// - POST /api/messages - Send message
/// - POST /api/messages/channel - Send channel message
/// - POST /api/contacts/sync - Trigger contact sync
/// - GET /api/messages/history - Get all messages
/// - GET /api/contacts - Get all contacts
/// - GET /api/status - Server health check
class SseServerService {
HttpServer? _server;
SseServerConfig? _config;
Registration? _bonjourRegistration;
/// Active SSE connections for messages
final Set<StreamController<String>> _messageStreams = {};
/// Active SSE connections for contacts
final Set<StreamController<String>> _contactStreams = {};
/// Message history (for new clients)
final List<Message> _messageHistory = [];
/// Contact list (for new clients)
final Map<String, Contact> _contacts = {};
/// Timer for cleaning up dead connections
Timer? _cleanupTimer;
/// Device name (for status endpoint)
String? _deviceName;
/// Set device name
void setDeviceName(String? name) {
_deviceName = name;
debugPrint('📝 [SseServer] Device name set to: $name');
}
/// Callback for when a client requests to send a message
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
/// Callback for when a client requests to send a channel message
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
/// Callback for when a client requests contact sync
Future<void> Function()? onSyncContacts;
/// Check if server is running
bool get isRunning => _server != null;
/// Get current configuration
SseServerConfig? get config => _config;
/// Get number of connected clients
int get connectedClients => _messageStreams.length;
/// CORS middleware
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, Authorization',
});
},
);
}
/// Start the SSE server
Future<void> startServer(SseServerConfig config) async {
if (_server != null) {
debugPrint('⚠️ [SseServer] Server already running');
return;
}
_config = config;
try {
debugPrint('🚀 [SseServer] Starting server on ${config.host}:${config.port}');
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
.addMiddleware(_corsHeaders)
.addMiddleware(shelf.logRequests())
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(
handler,
config.host,
config.port,
);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
// Start cleanup timer for dead connections
_startCleanupTimer();
// Register Bonjour/mDNS service
await _registerBonjourService(config);
} catch (e) {
debugPrint('❌ [SseServer] Failed to start server: $e');
_server = null;
rethrow;
}
}
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint('📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...');
_bonjourRegistration = await register(
const Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: 0, // Will be set dynamically
),
);
// Update with actual port
if (_bonjourRegistration != null) {
// Unregister and re-register with correct port
await unregister(_bonjourRegistration!);
_bonjourRegistration = await register(
Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: config.port,
),
);
debugPrint('✅ [SseServer] Bonjour service registered on port ${config.port}');
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
// Don't throw - server can still work without Bonjour
}
}
/// Start cleanup timer to remove dead connections
void _startCleanupTimer() {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
_cleanupDeadConnections();
});
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
}
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams.where((s) => s.isClosed).toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams.where((s) => s.isClosed).toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams');
debugPrint(' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients');
}
}
/// Stop the SSE server
Future<void> stopServer() async {
if (_server == null) {
return;
}
debugPrint('🛑 [SseServer] Stopping server...');
// Stop cleanup timer
_cleanupTimer?.cancel();
_cleanupTimer = null;
// Close all SSE streams
for (final stream in _messageStreams) {
await stream.close();
}
_messageStreams.clear();
for (final stream in _contactStreams) {
await stream.close();
}
_contactStreams.clear();
// Unregister Bonjour service
if (_bonjourRegistration != null) {
try {
await unregister(_bonjourRegistration!);
debugPrint('✅ [SseServer] Bonjour service unregistered');
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
}
_bonjourRegistration = null;
}
// Close HTTP server
await _server!.close(force: true);
_server = null;
_config = null;
debugPrint('✅ [SseServer] Server stopped');
}
/// Main request handler
Future<shelf.Response> _handleRequest(shelf.Request request) async {
// Check authentication if token is configured
if (_config?.authToken != null) {
final authHeader = request.headers['authorization'];
if (authHeader != 'Bearer ${_config!.authToken}') {
return shelf.Response.forbidden('Invalid authentication token');
}
}
final path = request.url.path;
final method = request.method;
debugPrint('📨 [SseServer] $method /$path');
// Route requests
if (method == 'GET' && path == 'sse/messages') {
return _handleSseMessages(request);
} else if (method == 'GET' && path == 'sse/contacts') {
return _handleSseContacts(request);
} else if (method == 'POST' && path == 'api/messages') {
return _handlePostMessage(request);
} else if (method == 'POST' && path == 'api/messages/channel') {
return _handlePostChannelMessage(request);
} else if (method == 'POST' && path == 'api/contacts/sync') {
return _handlePostContactsSync(request);
} else if (method == 'GET' && path == 'api/messages/history') {
return _handleGetMessageHistory(request);
} else if (method == 'GET' && path == 'api/contacts') {
return _handleGetContacts(request);
} else if (method == 'GET' && path == 'api/status') {
return _handleGetStatus(request);
} else if (method == 'GET' && path == '') {
return _handleRoot(request);
}
return shelf.Response.notFound('Not found');
}
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (messages) via hijack');
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_messageStreams.add(controller);
debugPrint(' Total clients: ${_messageStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial message history
for (final message in _messageHistory) {
final event = _formatSseEvent('message', _messageToJson(message));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_messageStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
debugPrint(' Total clients: ${_messageStreams.length}');
});
}
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint('📥 [SseServer] New SSE client connected (contacts) via hijack');
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_contactStreams.add(controller);
debugPrint(' Total clients: ${_contactStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial contact list
for (final contact in _contacts.values) {
final event = _formatSseEvent('contact', _contactToJson(contact));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_contactStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
debugPrint(' Total clients: ${_contactStreams.length}');
});
}
/// Handle POST message request
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final recipientPublicKey = json['recipientPublicKey'] as String;
final text = json['text'] as String;
if (onSendMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send message callback not configured'}),
);
}
final success = await onSendMessage!(recipientPublicKey, text);
return shelf.Response.ok(
jsonEncode({'success': success}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final channelIdx = json['channelIdx'] as int;
final text = json['text'] as String;
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send channel message callback not configured'}),
);
}
await onSendChannelMessage!(channelIdx, text);
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST contacts sync request
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
try {
if (onSyncContacts == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
);
}
await onSyncContacts!();
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle GET message history request
shelf.Response _handleGetMessageHistory(shelf.Request request) {
final messages = _messageHistory.map(_messageToJson).toList();
return shelf.Response.ok(
jsonEncode({'messages': messages}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET contacts request
shelf.Response _handleGetContacts(shelf.Request request) {
final contacts = _contacts.values.map(_contactToJson).toList();
return shelf.Response.ok(
jsonEncode({'contacts': contacts}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET status request
shelf.Response _handleGetStatus(shelf.Request request) {
return shelf.Response.ok(
jsonEncode({
'status': 'running',
'connectedClients': connectedClients,
'messageCount': _messageHistory.length,
'contactCount': _contacts.length,
'deviceName': _deviceName,
}),
headers: {'content-type': 'application/json'},
);
}
/// Handle root request (landing page)
shelf.Response _handleRoot(shelf.Request request) {
final html = '''
<!DOCTYPE html>
<html>
<head>
<title>MeshCore SAR - SSE Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; }
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 MeshCore SAR Server</h1>
<div class="status">✅ Server is running</div>
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
<h2>📡 SSE Endpoints</h2>
<div class="endpoint">GET /sse/messages</div>
<div class="endpoint">GET /sse/contacts</div>
<h2>🔧 API Endpoints</h2>
<div class="endpoint">POST /api/messages</div>
<div class="endpoint">POST /api/messages/channel</div>
<div class="endpoint">POST /api/contacts/sync</div>
<div class="endpoint">GET /api/messages/history</div>
<div class="endpoint">GET /api/contacts</div>
<div class="endpoint">GET /api/status</div>
<h2>📊 Stats</h2>
<p>Connected clients: <strong id="clients">Loading...</strong></p>
<p>Messages: <strong id="messages">Loading...</strong></p>
<p>Contacts: <strong id="contacts">Loading...</strong></p>
</div>
<script>
async function updateStats() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('clients').textContent = data.connectedClients;
document.getElementById('messages').textContent = data.messageCount;
document.getElementById('contacts').textContent = data.contactCount;
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>
''';
return shelf.Response.ok(
html,
headers: {'content-type': 'text/html'},
);
}
/// Broadcast a new message to all SSE clients
void broadcastMessage(Message message) {
// Add to history (limit to 1000 messages)
_messageHistory.add(message);
if (_messageHistory.length > 1000) {
_messageHistory.removeAt(0);
}
// Broadcast to all connected clients
final event = _formatSseEvent('message', _messageToJson(message));
final deadStreams = <StreamController<String>>[];
for (final stream in _messageStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast');
}
debugPrint('📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients');
}
/// Broadcast a new or updated contact to all SSE clients
void broadcastContact(Contact contact) {
// Update contact list
_contacts[contact.publicKeyHex] = contact;
// Broadcast to all connected clients
final event = _formatSseEvent('contact', _contactToJson(contact));
final deadStreams = <StreamController<String>>[];
for (final stream in _contactStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send to stream, marking as dead: $e');
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError((e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'));
}
if (deadStreams.isNotEmpty) {
debugPrint('🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast');
}
debugPrint('📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients');
}
/// Format SSE event
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
return 'event: $eventType\ndata: $jsonData\n\n';
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsCoordinates': message.sarGpsCoordinates != null
? {
'latitude': message.sarGpsCoordinates!.latitude,
'longitude': message.sarGpsCoordinates!.longitude,
}
: null,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
'receivedAt': message.receivedAt.toIso8601String(),
'senderName': message.senderName,
'deliveryStatus': message.deliveryStatus.name,
'expectedAckTag': message.expectedAckTag,
'suggestedTimeoutMs': message.suggestedTimeoutMs,
'roundTripTimeMs': message.roundTripTimeMs,
'deliveredAt': message.deliveredAt?.toIso8601String(),
'recipientPublicKey': message.recipientPublicKey?.toList(),
'retryAttempt': message.retryAttempt,
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
'usedFloodFallback': message.usedFloodFallback,
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};
}
/// Convert Contact to JSON
Map<String, dynamic> _contactToJson(Contact contact) {
return {
'publicKey': contact.publicKey.toList(),
'publicKeyHex': contact.publicKeyHex,
'type': contact.type.value,
'flags': contact.flags,
'outPathLen': contact.outPathLen,
'outPath': contact.outPath.toList(),
'advName': contact.advName,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? {
'batteryPercentage': contact.telemetry!.batteryPercentage,
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
'temperature': contact.telemetry!.temperature,
'humidity': contact.telemetry!.humidity,
'pressure': contact.telemetry!.pressure,
'gpsLocation': contact.telemetry!.gpsLocation != null
? {
'latitude': contact.telemetry!.gpsLocation!.latitude,
'longitude': contact.telemetry!.gpsLocation!.longitude,
}
: null,
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
}
: null,
};
}
/// Clear message history
void clearMessageHistory() {
_messageHistory.clear();
}
/// Clear contact list
void clearContacts() {
_contacts.clear();
}
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
import 'package:mbtiles/mbtiles.dart';
import '../models/map_layer.dart';
class TileCacheService {
static const String _storeName = 'meshcore_sar_tiles';
// Global flag to ensure ObjectBox is only initialized once
static bool _objectBoxInitialized = false;
static final _initLock = <String, Future<void>>{};
late final FMTCStore _store;
bool _isInitialized = false;
bool _isDownloading = false;
Future<void> initialize() async {
if (_isInitialized) return;
// Ensure we only initialize ObjectBox once globally
if (!_objectBoxInitialized) {
// Use a lock to prevent concurrent initialization attempts
final initFuture = _initLock.putIfAbsent('objectbox', () async {
try {
await FMTCObjectBoxBackend().initialise();
_objectBoxInitialized = true;
} catch (e) {
// Already initialized or error - that's okay
_objectBoxInitialized = true;
}
});
await initFuture;
}
try {
_store = FMTCStore(_storeName);
await _store.manage.create();
_isInitialized = true;
} catch (e) {
// Store might already exist
_store = FMTCStore(_storeName);
_isInitialized = true;
}
}
FMTCTileProvider getTileProvider(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
/// Get tile provider for WMS layers with caching support
/// WMS layers require special handling because they use WMSTileLayerOptions
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (!layer.isWms) {
throw ArgumentError('Layer must be a WMS layer');
}
// Return the same cached tile provider
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
Future<void> downloadRegion({
required MapLayer layer,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
Function(double progress)? onProgress,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (_isDownloading) {
throw StateError('A download is already in progress. Cancel it first.');
}
_isDownloading = true;
try {
final region = RectangleRegion(bounds);
final downloadable = region.toDownloadable(
minZoom: minZoom,
maxZoom: maxZoom,
options: TileLayer(urlTemplate: layer.urlTemplate),
);
final download = _store.download.startForeground(region: downloadable);
await for (final progress in download.downloadProgress) {
if (onProgress != null && progress.maxTilesCount > 0) {
// Use attemptedTilesCount instead of successfulTilesCount
// attemptedTilesCount includes successful + buffered + skipped tiles
final percentage = progress.percentageProgress;
debugPrint(
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
);
onProgress(percentage);
}
}
} finally {
_isDownloading = false;
}
}
Future<void> cancelDownload() async {
if (!_isInitialized) return;
await _store.download.cancel();
}
Future<void> clearCache() async {
if (!_isInitialized) return;
await _store.manage.delete();
await _store.manage.create();
}
Future<int> getCachedTileCount() async {
if (!_isInitialized) return 0;
final stats = await _store.stats.length;
return stats;
}
Future<double> getCacheSizeMB() async {
if (!_isInitialized) return 0.0;
final stats = await _store.stats.size;
return stats / (1024 * 1024);
}
Future<List<String>> getAvailableStores() async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
final stores = await FMTCRoot.stats.storesAvailable;
return stores.map((store) => store.storeName).toList();
}
Future<Map<String, dynamic>> getStoreStats() async {
if (!_isInitialized) return {};
final length = await _store.stats.length;
final size = await _store.stats.all.then((a) => a.size);
return {
'tileCount': length,
'sizeMB': size / 1024,
'storeName': _storeName,
};
}
/// Get vector tile provider for MBTiles layers
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
if (!layer.isVector || layer.mbtilesFile == null) {
return null;
}
try {
final mbtiles = MbTiles(
mbtilesPath: layer.mbtilesFile!.path,
gzip: layer.isGzipped ?? false,
);
return MbTilesVectorTileProvider(
mbtiles: mbtiles,
);
} catch (e) {
debugPrint('Error creating vector tile provider: $e');
return null;
}
}
/// Export the current tile cache store to an archive file
///
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
///
/// Returns the number of tiles exported
Future<int> exportStore(String outputPath) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: outputPath);
final result = await external.export(storeNames: [_storeName]);
debugPrint('Export completed: $result tiles exported to $outputPath');
return result;
} catch (e) {
debugPrint('Error exporting store: $e');
rethrow;
}
}
/// Import a tile cache store from an archive file
///
/// [filePath] - Path to the .fmtc archive file to import
/// [storeNames] - Optional list of store names to import (null = import all)
/// [strategy] - Conflict resolution strategy (default: merge)
///
/// Returns a map with import statistics (e.g., tile count, stores imported)
Future<Map<String, dynamic>> importStore(
String filePath, {
List<String>? storeNames,
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final result = external.import(storeNames: storeNames, strategy: strategy);
// Wait for the import to complete and get tile count
final tileCount = await result.complete;
// Wait for store states
final storesToStates = await result.storesToStates;
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
// Count successful stores (those that weren't skipped)
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
return {
'successfulStores': successfulCount,
'tileCount': tileCount,
'storesToStates': storesToStates,
};
} catch (e) {
debugPrint('Error importing store: $e');
rethrow;
}
}
/// List all stores available in an archive file without importing
///
/// [filePath] - Path to the .fmtc archive file to inspect
///
/// Returns a list of store names contained in the archive
Future<List<String>> listArchiveStores(String filePath) async {
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final stores = await external.listStores;
debugPrint('Archive contains ${stores.length} stores: $stores');
return stores;
} catch (e) {
debugPrint('Error listing archive stores: $e');
rethrow;
}
}
void dispose() {
_isInitialized = false;
}
}

View 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);
}
}

View File

@@ -0,0 +1,135 @@
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 manifest from server
class UpdateCheckerService {
static final UpdateCheckerService _instance = UpdateCheckerService._internal();
factory UpdateCheckerService() => _instance;
UpdateCheckerService._internal();
final BuildInfoService _buildInfoService = BuildInfoService();
// Manifest URL for the latest unstable build
static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json';
/// 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 manifest from: $_manifestUrl');
// Fetch manifest from server
final response = await http.get(
Uri.parse(_manifestUrl),
headers: {'Accept': 'application/json'},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('[UpdateChecker] Manifest fetch timed out');
throw Exception('Manifest fetch timed out');
},
);
if (response.statusCode != 200) {
debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}');
return UpdateInfo.noUpdate(currentCommitHash);
}
// Parse manifest JSON
final Map<String, dynamic> manifest = json.decode(response.body);
final latestCommitHash = manifest['commit'] as String?;
final commitShort = manifest['commit_short'] as String?;
final buildId = manifest['build_id'] as String?;
final timestamp = manifest['timestamp'] as String?;
final artifacts = manifest['artifacts'] as List<dynamic>?;
if (latestCommitHash == null || commitShort == null) {
debugPrint('[UpdateChecker] Invalid manifest: missing commit information');
return UpdateInfo.noUpdate(currentCommitHash);
}
debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash');
debugPrint('[UpdateChecker] Latest commit short: $commitShort');
// Compare commit hashes
// Current hash might be full SHA or short (7 chars)
// Latest from manifest is full SHA
final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash);
if (!isUpdateAvailable) {
debugPrint('[UpdateChecker] No update available (same commit)');
return UpdateInfo.noUpdate(currentCommitHash);
}
// Find Android APK in artifacts
final String? apkUrl = _findAndroidApkUrl(artifacts);
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: commitShort,
downloadUrl: apkUrl,
buildId: buildId,
timestamp: timestamp,
);
} 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;
}
/// Find Android APK URL in artifacts list
String? _findAndroidApkUrl(List<dynamic>? artifacts) {
if (artifacts == null || artifacts.isEmpty) return null;
// Look for .apk file in artifacts
for (final artifact in artifacts) {
if (artifact is String && artifact.toLowerCase().endsWith('.apk')) {
// Construct full URL
return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact';
}
}
return null;
}
}

View 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';
}
}

View 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);
}
}

View 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);
}