Files
meshcore-sar_android/lib/providers/app_provider.dart
Janez T e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00

3819 lines
127 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader;
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
import 'messages_provider.dart';
import 'drawing_provider.dart';
import 'channels_provider.dart';
import 'voice_provider.dart';
import 'image_provider.dart' as ip;
import 'helpers/fragment_ack_wait_registry.dart';
import 'helpers/session_metadata_restore.dart';
import '../services/location_tracking_service.dart';
import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart';
import '../utils/rssi_location_estimator.dart';
import '../services/profiles_feature_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/notification_service.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../models/path_selection.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession {
final PathSelection currentSelection;
final ParsedContactRoute? originalRoute;
final bool routerFallbackAttempted;
const _DirectMessageRouteSession({
required this.currentSelection,
required this.originalRoute,
required this.routerFallbackAttempted,
});
_DirectMessageRouteSession copyWith({
PathSelection? currentSelection,
ParsedContactRoute? originalRoute,
bool? routerFallbackAttempted,
}) {
return _DirectMessageRouteSession(
currentSelection: currentSelection ?? this.currentSelection,
originalRoute: originalRoute ?? this.originalRoute,
routerFallbackAttempted:
routerFallbackAttempted ?? this.routerFallbackAttempted,
);
}
}
class _ParsedRawAdvert {
final Uint8List publicKey;
final String? advName;
final int typeValue;
final int flags;
final int lastAdvert;
final int? advLat;
final int? advLon;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
const _ParsedRawAdvert({
required this.publicKey,
required this.advName,
required this.typeValue,
required this.flags,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.signedEncodedPathLen,
required this.paddedPathBytes,
});
}
class _ParsedRepeaterStatus {
final int batteryMv;
final int queueLen;
final int lastRssi;
final int lastSnrRaw;
final int uptimeSecs;
const _ParsedRepeaterStatus({
required this.batteryMv,
required this.queueLen,
required this.lastRssi,
required this.lastSnrRaw,
required this.uptimeSecs,
});
}
class _PendingRepeaterOwnerRequest {
final Uint8List publicKey;
const _PendingRepeaterOwnerRequest({required this.publicKey});
}
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3;
static const int _rawPayloadTypeAdvert = 0x04;
static const int _routeTransportFlood = 0x00;
static const int _routeTransportDirect = 0x03;
static const int _anonReqTypeOwner = 0x02;
static const double _lowBatteryThresholdPercent = 30.0;
static const double _lowBatteryResetThresholdPercent = 35.0;
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
static const Duration _repeaterOwnerInfoRequestCooldown = Duration(
minutes: 10,
);
@visibleForTesting
static bool isDeletedChannelInfo(
int channelIdx,
String channelName,
Uint8List secret,
) {
return channelIdx != 0 &&
channelName.isEmpty &&
secret.every((byte) => byte == 0);
}
@visibleForTesting
static String channelContactName(int channelIdx, String channelName) {
return channelName.isEmpty && channelIdx != 0
? 'Channel $channelIdx'
: channelName;
}
final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final ip.ImageProvider imageProvider;
final LocationTrackingService locationTrackingService =
LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
bool get isSimpleMode => true;
bool _isMapEnabled = true;
bool get isMapEnabled => _isMapEnabled;
bool _isContactsEnabled = true;
bool get isContactsEnabled => _isContactsEnabled;
bool _isSensorsEnabled = true;
bool get isSensorsEnabled => _isSensorsEnabled;
bool _isVoiceSilenceTrimmingEnabled = true;
bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled;
bool _isVoiceBandPassFilterEnabled = true;
bool get isVoiceBandPassFilterEnabled => _isVoiceBandPassFilterEnabled;
bool _isVoiceCompressorEnabled = true;
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
bool _isVoiceAutoGainEnabled = false;
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
bool _isVoiceEchoCancellationEnabled = false;
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
bool _isVoiceNoiseSuppressionEnabled = false;
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
bool _nearestRelayFallbackEnabled =
MessagingRoutePreferences.defaultNearestRelayFallbackEnabled;
bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled;
final PathHistoryService _pathHistoryService = PathHistoryService();
final NearestRouterSelector _nearestRouterSelector =
const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
static const int _maxPacketRetryAttempts = 4;
final Map<String, String> _voiceSessionSenderKey6 = {};
final Map<String, String> _imageSessionSenderKey6 = {};
final Map<String, Timer> _voiceMissingRetryTimers = {};
final Map<String, int> _voiceMissingRetryAttempts = {};
final Map<String, Timer> _imageMissingRetryTimers = {};
final Map<String, int> _imageMissingRetryAttempts = {};
final FragmentAckWaitRegistry _rawProbeWaiters = FragmentAckWaitRegistry();
final Map<String, Future<bool>> _pendingRawRouteProbes = {};
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {};
final Map<String, DateTime> _recentRepeaterStatusRequests = {};
final Map<String, DateTime> _recentRepeaterOwnerInfoRequests = {};
final Map<int, _PendingRepeaterOwnerRequest> _pendingRepeaterOwnerRequests =
{};
bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer;
Timer? _lowBatteryCheckTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
AppProvider({
required this.connectionProvider,
required this.contactsProvider,
required this.messagesProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.voiceProvider,
required this.imageProvider,
}) {
_setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking();
_loadMapEnabled();
_loadContactsEnabled();
_loadSensorsEnabled();
_loadVoiceSilenceTrimmingEnabled();
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale();
_loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence();
_startLowBatteryWatcher();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true;
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
void _startPacketCapturePersistence() {
_packetCaptureFlushTimer?.cancel();
_packetCaptureFlushTimer = Timer.periodic(const Duration(seconds: 2), (_) {
unawaited(_flushPacketCaptureLogs());
});
unawaited(_flushPacketCaptureLogs());
}
void _startLowBatteryWatcher() {
_lowBatteryCheckTimer?.cancel();
_lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) {
unawaited(_checkLowBatteryAlerts());
});
unawaited(_checkLowBatteryAlerts());
}
Future<void> _checkLowBatteryAlerts() async {
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
if (deviceBattery != null &&
deviceBattery > _lowBatteryResetThresholdPercent) {
_lowBatteryNotifiedNodeIds.remove('device');
}
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
await _notifyLowBatteryIfNeeded(
nodeId: 'device',
nodeName:
connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: (connectionProvider.deviceInfo.displayName ?? 'Connected device'),
batteryPercent: deviceBattery,
isCurrentDevice: true,
);
}
}
Future<void> _notifyLowBatteryIfNeeded({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (batteryPercent >= _lowBatteryThresholdPercent) {
return;
}
if (_lowBatteryNotifiedNodeIds.contains(nodeId)) {
return;
}
final shown = await _notificationService.showLowBatteryNotification(
nodeId: nodeId,
nodeName: nodeName,
batteryPercent: batteryPercent,
isCurrentDevice: isCurrentDevice,
);
if (shown) {
_lowBatteryNotifiedNodeIds.add(nodeId);
}
}
String _packetLogSignature(BlePacketLog log) {
final prefix = log.rawData.length <= 12
? log.rawData
: log.rawData.sublist(0, 12);
final prefixHex = prefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return '${log.timestamp.microsecondsSinceEpoch}|'
'${log.direction.name}|${log.responseCode ?? -1}|'
'${log.rawData.length}|$prefixHex';
}
Future<void> _flushPacketCaptureLogs() async {
if (_isPersistingPacketCapture) return;
_isPersistingPacketCapture = true;
try {
final logs = connectionProvider.bleService.packetLogs;
if (logs.isEmpty) return;
List<BlePacketLog> toPersist = const [];
if (_lastPersistedPacketSignature == null) {
toPersist = logs;
} else {
final lastSig = _lastPersistedPacketSignature!;
var lastIndex = -1;
for (var i = logs.length - 1; i >= 0; i--) {
if (_packetLogSignature(logs[i]) == lastSig) {
lastIndex = i;
break;
}
}
if (lastIndex == -1) {
// In-memory log rotated or cleared; persist current window to avoid gaps.
toPersist = logs;
} else if (lastIndex < logs.length - 1) {
toPersist = logs.sublist(lastIndex + 1);
}
}
if (toPersist.isNotEmpty) {
await packetCaptureStorageService.appendLogs(toPersist);
}
_lastPersistedPacketSignature = _packetLogSignature(logs.last);
} catch (e) {
debugPrint('❌ [AppProvider] Packet capture flush failed: $e');
} finally {
_isPersistingPacketCapture = false;
}
}
/// Sync drawings from messages on app startup (before BLE connection)
Future<void> _syncDrawingsOnStartup() async {
// Wait for both MessagesProvider and DrawingProvider to finish initializing
int attempts = 0;
while ((!messagesProvider.isInitialized ||
!drawingProvider.isInitialized) &&
attempts < 40) {
await Future.delayed(const Duration(milliseconds: 50));
attempts++;
}
_restoreSessionMetadataFromMessages();
debugPrint(
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
}
void _restoreSessionMetadataFromMessages() {
final restored = restoreSessionMetadataFromMessages(
messagesProvider.messages,
);
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
_imageSessionSenderKey6.addAll(restored.imageSenderKeyBySession);
for (final entry in restored.imageEnvelopeBySession.entries) {
imageProvider.registerEnvelope(entry.value);
}
final restoredVoice = restored.voiceSenderKeyBySession.length;
final restoredImage = restored.imageEnvelopeBySession.length;
if (restoredVoice > 0 || restoredImage > 0) {
debugPrint(
'🔄 [AppProvider] Restored session metadata from messages: '
'$restoredVoice voice, $restoredImage image',
);
}
}
String? _resolveContactNameForNotification(Uint8List? publicKey) {
if (publicKey == null || publicKey.isEmpty) return null;
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName = _preferredSelfDisplayName(
deviceName: connectionProvider.deviceInfo.deviceName,
selfName: connectionProvider.deviceInfo.selfName,
);
if (_matchesPublicKeyPrefix(publicKey, ownPublicKey) &&
ownName != null &&
ownName.trim().isNotEmpty) {
return ownName;
}
Contact? contact;
if (publicKey.length >= 32) {
contact = contactsProvider.findContactByKey(publicKey);
}
contact ??= publicKey.length >= 6
? contactsProvider.findContactByPrefix(
Uint8List.fromList(publicKey.sublist(0, 6)),
)
: null;
return contact?.advName;
}
static bool shouldIgnoreSelfReplay({
required Message message,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
if ((!message.isContactMessage && !message.isChannelMessage) ||
message.pathLen > 0) {
return false;
}
if (_matchesPublicKeyPrefix(message.senderPublicKeyPrefix, ownPublicKey)) {
return true;
}
final trimmedSenderName = message.senderName?.trim();
final trimmedOwnName = ownName?.trim();
if (trimmedSenderName == null ||
trimmedSenderName.isEmpty ||
trimmedOwnName == null ||
trimmedOwnName.isEmpty) {
return false;
}
return trimmedSenderName == trimmedOwnName;
}
static String? preferredSelfDisplayName({
required String? deviceName,
required String? selfName,
}) => _preferredSelfDisplayName(deviceName: deviceName, selfName: selfName);
static String? _preferredSelfDisplayName({
required String? deviceName,
required String? selfName,
}) {
final trimmedSelfName = selfName?.trim();
if (trimmedSelfName != null && trimmedSelfName.isNotEmpty) {
return trimmedSelfName;
}
final trimmedDeviceName = deviceName?.trim();
if (trimmedDeviceName == null || trimmedDeviceName.isEmpty) {
return null;
}
if (trimmedDeviceName.toLowerCase().startsWith('meshcore-')) {
final stripped = trimmedDeviceName.substring('meshcore-'.length).trim();
if (stripped.isNotEmpty) {
return stripped;
}
}
return trimmedDeviceName;
}
static bool _matchesPublicKeyPrefix(
Uint8List? candidateKey,
Uint8List? ownPublicKey,
) {
if (candidateKey == null ||
candidateKey.isEmpty ||
ownPublicKey == null ||
ownPublicKey.isEmpty) {
return false;
}
final compareLength = candidateKey.length >= 6 && ownPublicKey.length >= 6
? 6
: (candidateKey.length < ownPublicKey.length
? candidateKey.length
: ownPublicKey.length);
if (compareLength <= 0) {
return false;
}
for (var index = 0; index < compareLength; index++) {
if (candidateKey[index] != ownPublicKey[index]) {
return false;
}
}
return true;
}
/// Load map enabled setting from shared preferences
Future<void> _loadMapEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isMapEnabled = prefs.getBool(_scopedKey('map_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading map enabled setting: $e');
}
}
/// Toggle map on/off
Future<void> toggleMapEnabled(bool enabled) async {
try {
_isMapEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('map_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving map enabled setting: $e');
}
}
/// Load contacts enabled setting from shared preferences
Future<void> _loadContactsEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isContactsEnabled =
prefs.getBool(_scopedKey('contacts_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading contacts enabled setting: $e');
}
}
/// Toggle contacts tab on/off
Future<void> toggleContactsEnabled(bool enabled) async {
try {
_isContactsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('contacts_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving contacts enabled setting: $e');
}
}
/// Load sensors enabled setting from shared preferences
Future<void> _loadSensorsEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isSensorsEnabled = prefs.getBool(_scopedKey('sensors_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading sensors enabled setting: $e');
}
}
/// Toggle sensors tab on/off
Future<void> toggleSensorsEnabled(bool enabled) async {
try {
_isSensorsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('sensors_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving sensors enabled setting: $e');
}
}
/// Load voice silence trimming setting from shared preferences.
Future<void> _loadVoiceSilenceTrimmingEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceSilenceTrimmingEnabled =
prefs.getBool(_scopedKey('voice_silence_trimming_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice silence trimming setting: $e');
}
}
/// Toggle voice silence trimming on/off.
Future<void> toggleVoiceSilenceTrimmingEnabled(bool enabled) async {
try {
_isVoiceSilenceTrimmingEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('voice_silence_trimming_enabled'),
enabled,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice silence trimming setting: $e');
}
}
/// Load voice band-pass filter setting from shared preferences.
Future<void> _loadVoiceBandPassFilterEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceBandPassFilterEnabled =
prefs.getBool(_scopedKey('voice_band_pass_filter_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice band-pass filter setting: $e');
}
}
/// Toggle voice band-pass filter on/off.
Future<void> toggleVoiceBandPassFilterEnabled(bool enabled) async {
try {
_isVoiceBandPassFilterEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('voice_band_pass_filter_enabled'),
enabled,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice band-pass filter setting: $e');
}
}
/// Load voice compressor setting from shared preferences.
Future<void> _loadVoiceCompressorEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceCompressorEnabled =
prefs.getBool(_scopedKey('voice_compressor_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice compressor setting: $e');
}
}
/// Toggle voice compressor on/off.
Future<void> toggleVoiceCompressorEnabled(bool enabled) async {
try {
_isVoiceCompressorEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('voice_compressor_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice compressor setting: $e');
}
}
/// Load voice limiter setting from shared preferences.
Future<void> _loadVoiceLimiterEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceLimiterEnabled =
prefs.getBool(_scopedKey('voice_limiter_enabled')) ?? true;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice limiter setting: $e');
}
}
/// Toggle voice limiter on/off.
Future<void> toggleVoiceLimiterEnabled(bool enabled) async {
try {
_isVoiceLimiterEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('voice_limiter_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice limiter setting: $e');
}
}
Future<void> _loadVoiceAutoGainEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled =
prefs.getBool(_scopedKey('voice_auto_gain_enabled')) ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice auto gain setting: $e');
}
}
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
try {
_isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('voice_auto_gain_enabled'), enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice auto gain setting: $e');
}
}
Future<void> _loadVoiceEchoCancellationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled =
prefs.getBool(_scopedKey('voice_echo_cancellation_enabled')) ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e');
}
}
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
try {
_isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('voice_echo_cancellation_enabled'),
enabled,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e');
}
}
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled =
prefs.getBool(_scopedKey('voice_noise_suppression_enabled')) ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice noise suppression setting: $e');
}
}
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
try {
_isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('voice_noise_suppression_enabled'),
enabled,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice noise suppression setting: $e');
}
}
Future<void> _loadMessageFontScale() async {
try {
final prefs = await SharedPreferences.getInstance();
_messageFontScale =
(prefs.getDouble(_scopedKey('message_font_scale')) ?? 1.0).clamp(
0.85,
1.4,
);
notifyListeners();
} catch (e) {
debugPrint('Error loading message font scale setting: $e');
}
}
Future<void> setMessageFontScale(double scale) async {
try {
_messageFontScale = scale.clamp(0.85, 1.4);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(
_scopedKey('message_font_scale'),
_messageFontScale,
);
notifyListeners();
} catch (e) {
debugPrint('Error saving message font scale setting: $e');
}
}
Future<void> _loadMessagingRouteSettings() async {
try {
_autoRouteRotationEnabled =
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled =
await MessagingRoutePreferences.getNearestRelayFallbackEnabled();
notifyListeners();
} catch (e) {
debugPrint('Error loading messaging route settings: $e');
}
}
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
try {
_autoRouteRotationEnabled = enabled;
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto route rotation setting: $e');
}
}
Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try {
_clearPathOnMaxRetry = enabled;
await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving clear path on max retry setting: $e');
}
}
Future<void> toggleNearestRelayFallbackEnabled(bool enabled) async {
try {
_nearestRelayFallbackEnabled = enabled;
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving nearest relay fallback setting: $e');
}
}
/// Initialize location tracking service
Future<void> _initializeLocationTracking() async {
try {
// Initialize location tracking with BLE service
await locationTrackingService.initialize(connectionProvider.bleService);
// Setup callbacks
locationTrackingService.onPositionUpdate = (position) {
debugPrint(
'📍 [AppProvider] Position updated: ${position.latitude}, ${position.longitude}',
);
};
locationTrackingService.onBroadcastSent = (position) {
debugPrint('📡 [AppProvider] Position broadcast to mesh network');
};
locationTrackingService.onError = (error) {
debugPrint('❌ [AppProvider] Location tracking error: $error');
};
locationTrackingService.onTrackingStateChanged = (isTracking) {
debugPrint(
'🔄 [AppProvider] Location tracking state: ${isTracking ? "started" : "stopped"}',
);
};
locationTrackingService.onFastLocationUpdate = (position, reason) {
unawaited(_sendFastLocationUpdate(position, reason: reason));
};
debugPrint('✅ [AppProvider] Location tracking service initialized');
} catch (e) {
debugPrint('❌ [AppProvider] Error initializing location tracking: $e');
}
}
/// Setup callbacks between providers
void _setupCallbacks() {
// Monitor connection state changes to start/stop location tracking
connectionProvider.addListener(_handleConnectionStateChange);
messagesProvider.resolveContactNameCallback =
_resolveContactNameForNotification;
messagesProvider.resolveChannelNameCallback = (channelIdx) {
if (channelIdx == 0) {
return 'Public';
}
for (final channel in contactsProvider.channels) {
if (channel.publicKey.length > 1 &&
channel.publicKey[1] == channelIdx) {
return channel.displayName;
}
}
return channelsProvider.getChannelDisplayName(channelIdx);
};
voiceProvider.sendRawPacketCallback =
({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
}) async {
await connectionProvider.sendRawVoicePacket(
contactPath: contactPath,
contactPathLen: contactPathLen,
payload: payload,
);
};
// Image raw-packet serving reuses the same BLE raw-data path as voice.
imageProvider.sendRawPacketCallback =
({
required Uint8List contactPath,
required int contactPathLen,
required Uint8List payload,
}) async {
await connectionProvider.sendRawVoicePacket(
contactPath: contactPath,
contactPathLen: contactPathLen,
payload: payload,
);
};
// When a contact is received from BLE
connectionProvider.onContactReceivedDetailed = (contact, source) {
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final existingContact = contactsProvider.findContactByKey(
contact.publicKey,
);
final isAdvertSource =
source == ContactReceiveSource.advert ||
source == ContactReceiveSource.preview;
if (isAdvertSource) {
// For pushNewAdvert (0x8A): contact data is already in the push, but
// we also import into firmware so subsequent getContact calls work.
// Matches the official app which calls cmdGetAdvertPath for all adverts.
if (source == ContactReceiveSource.advert) {
unawaited(
connectionProvider.importReceivedAdvert(contact.publicKey),
);
}
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertContact(
contact,
devicePublicKey: devicePublicKey,
);
if (isNewPendingAdvert) {
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: contact.publicKey
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(),
contactName: contact.advName.trim().isEmpty
? null
: contact.advName,
),
);
}
if (contact.type == ContactType.repeater &&
contact.advName.trim().isEmpty) {
unawaited(_maybeRequestRepeaterOwnerInfo(contact.publicKey));
}
if (existingContact == null) {
return;
}
}
// Pass device public key to filter out our own contact
contactsProvider.addOrUpdateContact(
contact,
devicePublicKey: devicePublicKey,
);
unawaited(_pathHistoryService.recordLearnedPath(contact));
};
// When all contacts are received
connectionProvider.onContactsComplete = (contacts) {
// Pass device public key to filter out our own contact
contactsProvider.addContacts(
contacts,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
for (final contact in contacts) {
unawaited(_pathHistoryService.recordLearnedPath(contact));
}
debugPrint('Received ${contacts.length} contacts');
};
// Setup callback for ConnectionProvider to query channel info
connectionProvider.getChannelInfo = (int channelIdx) {
return channelsProvider.getChannel(channelIdx);
};
// When channel info is received
connectionProvider.onChannelInfoReceived =
(int channelIdx, String channelName, Uint8List secret, int? flags) {
try {
debugPrint(
'🔔 [AppProvider] onChannelInfoReceived called: idx=$channelIdx, name="$channelName"',
);
final isDeletedChannel = connectionProvider
.shouldTreatChannelInfoAsDeleted(
channelIdx,
channelName,
secret,
);
final isEmptyChannelInfo = AppProvider.isDeletedChannelInfo(
channelIdx,
channelName,
secret,
);
final contactChannelName = AppProvider.channelContactName(
channelIdx,
channelName,
);
// Only treat the slot as deleted when firmware returns an empty
// name and a zeroed secret. Protected channels may still have an
// empty display name but remain fully configured.
if (isDeletedChannel) {
debugPrint(
' 🗑️ Channel $channelIdx deleted - removing from providers',
);
messagesProvider.clearChannelMessages(channelIdx);
debugPrint(' ✅ Cleared channel messages');
// Remove from ChannelsProvider
channelsProvider.removeChannel(channelIdx);
debugPrint(' ✅ Removed from ChannelsProvider');
// Remove from ContactsProvider using pseudo public key
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final publicKeyHex = publicKeyBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
contactsProvider.removeContact(publicKeyHex);
debugPrint(' ✅ Removed from ContactsProvider');
return;
}
if (isEmptyChannelInfo) {
debugPrint(
' ⏭️ Ignoring empty channel slot $channelIdx during sync',
);
return;
}
// Add/update in ChannelsProvider
channelsProvider.addOrUpdateChannel(
index: channelIdx,
name: channelName,
secret: secret,
flags: flags,
);
debugPrint(' ✅ Added to ChannelsProvider');
// Also add as Contact to ContactsProvider (for UI display)
// Skip if it's public channel (already exists)
debugPrint(
'📻 [AppProvider] Channel $channelIdx: "$contactChannelName" (rawNameEmpty: ${channelName.isEmpty}, isHashChannel: ${channelName.startsWith('#')})',
);
if (channelIdx != 0) {
debugPrint(
' ✅ Adding channel $channelIdx to ContactsProvider as Contact',
);
// Create a pseudo public key for the channel based on its index
// Use channel index as a unique identifier (pad to 32 bytes)
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF; // Special marker for channels
publicKeyBytes[1] = channelIdx; // Channel index
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
contactsProvider.addOrUpdateContact(
Contact(
publicKey: publicKeyBytes,
type: ContactType.channel,
flags: flags ?? 0,
outPathLen: -1, // Flood mode for channels
outPath: Uint8List(0), // Empty path for channels
advName: contactChannelName,
lastAdvert: now,
advLat: 0, // Channels don't have location
advLon: 0,
lastMod: now,
isNew: false, // Don't mark channels as new
),
);
debugPrint(
' ✅ Channel contact added. Total channels in ContactsProvider: ${contactsProvider.channels.length}',
);
} else {
debugPrint(
' ⏭️ Skipping channel $channelIdx (isPublic: ${channelIdx == 0})',
);
}
} catch (e, stackTrace) {
debugPrint('❌ [AppProvider] Error in onChannelInfoReceived: $e');
debugPrint(' Stack trace: $stackTrace');
}
};
// When a message is received
connectionProvider.onMessageReceived = (message) {
if (message.isChannelMessage) {
debugPrint(
'📥 [AppProvider] Channel message received: '
'id=${message.id}, '
'senderName=${message.senderName ?? "-"}, '
'senderKey=${message.senderKeyShort ?? "-"}, '
'pathLen=${message.pathLen}, '
'ts=${message.senderTimestamp}, '
'text=${message.text}',
);
}
if (AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: connectionProvider.deviceInfo.publicKey,
ownName: _preferredSelfDisplayName(
deviceName: connectionProvider.deviceInfo.deviceName,
selfName: connectionProvider.deviceInfo.selfName,
),
)) {
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
return;
}
// Enrich message with sender name from contacts.
// For room-forwarded messages, resolve the original poster's name
// from roomPostAuthorPrefix instead of the room server's key.
Message enrichedMessage = message;
Contact? senderContact;
if (message.senderName == null) {
// Try room post author first (the actual person who posted)
if (message.roomPostAuthorPrefix != null) {
final author = contactsProvider.findContactByPrefix(
message.roomPostAuthorPrefix!,
);
if (author != null) {
senderContact = author;
enrichedMessage = message.copyWith(senderName: author.advName);
}
}
// Fall back to sender key (direct messages, or room with unknown author)
if (senderContact == null && message.senderPublicKeyPrefix != null) {
final contact = contactsProvider.findContactByKey(
message.senderPublicKeyPrefix!,
);
if (contact != null) {
senderContact = contact;
enrichedMessage = message.copyWith(senderName: contact.advName);
}
}
}
senderContact ??= message.senderPublicKeyPrefix != null
? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!)
: null;
senderContact ??= enrichedMessage.senderName != null
? contactsProvider.contacts
.where((c) => c.advName == enrichedMessage.senderName)
.firstOrNull
: null;
final contactLocationSnapshot = senderContact != null
? contactsProvider.buildMessageContactLocationSnapshot(
senderContact,
capturedAt: enrichedMessage.receivedAt,
)
: null;
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Estimate location for contacts without GPS using received path
if (senderContact != null &&
senderContact.displayLocation == null &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
_estimateContactLocationFromReceivedPath(
contact: senderContact,
receivedPathBytes: receivedPathBytes,
);
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
debugPrint('🎨 [AppProvider] Drawing message received, parsing...');
// Extract sender name from message packet metadata
final senderName = enrichedMessage.senderName ?? 'unknown';
final drawing = DrawingMessageParser.parseDrawingMessage(
enrichedMessage.text,
senderName: senderName,
messageId:
enrichedMessage.id, // Pass message ID for navigation linking
);
if (drawing != null) {
debugPrint(
'🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}',
);
debugPrint(' Drawing linked to message ID: ${enrichedMessage.id}');
drawingProvider.addReceivedDrawing(drawing);
// Update message to mark as drawing and link to drawing ID
final updatedMessage = enrichedMessage.copyWith(
isDrawing: true,
drawingId: drawing.id,
);
// Add the drawing message to chat with drawing metadata
// This allows users to click on the drawing message to navigate to it
messagesProvider.addMessage(
updatedMessage,
contactLookup: (name) => '',
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
} else {
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
}
return;
}
// Voice envelope message (new public/direct on-demand format).
final voiceEnvelope = VoiceEnvelope.tryParseText(enrichedMessage.text);
if (voiceEnvelope != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
_voiceSessionSenderKey6[voiceEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
voiceProvider.registerEnvelope(voiceEnvelope);
enrichedMessage = enrichedMessage.copyWith(
isVoice: true,
voiceId: voiceEnvelope.sessionId,
);
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty &&
contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (_) {
return '';
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
return;
}
// Image envelope (IE1): announce image availability.
final imageEnvelope = ImageEnvelope.tryParse(enrichedMessage.text);
if (imageEnvelope != null) {
final senderPrefix = enrichedMessage.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
_imageSessionSenderKey6[imageEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
imageProvider.registerEnvelope(imageEnvelope);
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty &&
contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (_) {
return '';
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
return;
}
// Pass contact lookup function to link channel messages with contacts
messagesProvider.addMessage(
enrichedMessage,
contactLookup: (name) {
// Find contact by name and return their public key hex (first 12 chars for 6 bytes)
try {
final contact = contactsProvider.contacts.firstWhere(
(c) => c.advName == name,
);
return contact.publicKeyHex.isNotEmpty &&
contact.publicKeyHex.length >= 12
? contact.publicKeyHex.substring(0, 12)
: '';
} catch (e) {
// No matching contact found
return '';
}
},
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
};
// Keep a compact receive-time snapshot because packet logs roll over.
// This lets the UI still show timing/link details after app restarts.
// When telemetry is received via PUSH_CODE_TELEMETRY_RESPONSE (0x8B)
// Used by older firmware versions for telemetry responses
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
debugPrint(
'📊 [AppProvider] Telemetry response (0x8B) received - updating contact',
);
contactsProvider.updateTelemetry(publicKey, lppData);
};
// When binary response is received via PUSH_CODE_BINARY_RESPONSE (0x8C)
// Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return;
}
debugPrint(
'📊 [AppProvider] Binary response (0x8C tag=$tag) received',
);
// Binary responses carry Cayenne LPP telemetry data.
// The data starts with a channel byte — valid LPP always has at least
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
if (responseData.length >= 3) {
contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
}
};
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)
// Magic 0x6d 'm' = swarm control; 0x72 'r' = voice fetch request.
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final parsedAdvert = _tryParseRawAdvert(payload);
if (parsedAdvert != null) {
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertMetadata(
publicKey: parsedAdvert.publicKey,
typeValue: parsedAdvert.typeValue,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
flags: parsedAdvert.flags,
advName: parsedAdvert.advName,
lastAdvert: parsedAdvert.lastAdvert,
advLat: parsedAdvert.advLat,
advLon: parsedAdvert.advLon,
signedEncodedPathLen: parsedAdvert.signedEncodedPathLen,
paddedPathBytes: parsedAdvert.paddedPathBytes,
rxRssiDbm: rssiDbm,
rxSnrRaw: snrRaw,
);
if (isNewPendingAdvert) {
final contactKey = parsedAdvert.publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: contactKey,
contactName: parsedAdvert.advName,
),
);
}
if (parsedAdvert.typeValue == ContactType.repeater.value) {
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
}
if (contactsProvider.shouldEnrichPendingAdvert(
parsedAdvert.publicKey,
)) {
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
}
return;
}
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(
sender.publicKey.sublist(0, 6),
fastGpsPacket,
);
}
return;
}
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
if (rawProbeRequest != null) {
debugPrint(
'📡 [AppProvider] Incoming raw route probe: nonce=${rawProbeRequest.nonce.toRadixString(16)} requester=${rawProbeRequest.requesterKey6}',
);
_handleRawRouteProbeRequest(rawProbeRequest);
return;
}
final rawProbeAck = RawRouteProbeAck.tryParseBinary(payload);
if (rawProbeAck != null) {
debugPrint(
'📡 [AppProvider] Incoming raw route probe ACK: nonce=${rawProbeAck.nonce.toRadixString(16)}',
);
_completeRawRouteProbeAck(rawProbeAck.nonce);
return;
}
final mediaSwarmRequest = MediaSwarmRequest.tryParseBinary(payload);
if (mediaSwarmRequest != null) {
_handleIncomingMediaSwarmRequest(mediaSwarmRequest);
return;
}
final mediaSwarmAvailability = MediaSwarmAvailability.tryParseBinary(
payload,
);
if (mediaSwarmAvailability != null) {
_handleIncomingMediaSwarmAvailability(mediaSwarmAvailability);
return;
}
final voiceFetchRequest = VoiceFetchRequest.tryParseBinary(payload);
if (voiceFetchRequest != null) {
debugPrint(
'🎙️ [AppProvider] Incoming voice fetch request: session=${voiceFetchRequest.sessionId} want=${voiceFetchRequest.want} requester=${voiceFetchRequest.requesterKey6}',
);
final requester = _resolveVoiceFetchRequester(voiceFetchRequest);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester contact not found (binary)',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Voice fetch requester too far: ${requester.routeHopCount} hops',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch voice for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
unawaited(() async {
final served = await voiceProvider.serveSessionTo(
sessionId: voiceFetchRequest.sessionId,
requester: requester,
requestedIndices: voiceFetchRequest.want == 'missing'
? voiceFetchRequest.missingIndices.toSet()
: null,
);
if (served) {
messagesProvider.recordMediaTransfer(
sessionId: voiceFetchRequest.sessionId,
mediaType: 'voice',
requesterKey6: voiceFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return;
}
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
if (imageFetchRequest != null) {
final requester = _resolveImageFetchRequester(imageFetchRequest);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester contact not found (binary) '
'for session ${imageFetchRequest.sessionId} / '
'${imageFetchRequest.requesterKey6}',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image: requester contact is unknown. Add/sync contacts first.',
level: 'warning',
);
return;
}
if (requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Image fetch requester too far: '
'${requester.routeHopCount} hops for session '
'${imageFetchRequest.sessionId}',
);
messagesProvider.logSystemMessage(
text:
'Cannot fetch image for ${requester.advName}: message is too far (${requester.routeHopCount} hops, max $_maxDirectPayloadHops).',
level: 'warning',
);
return;
}
debugPrint(
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
'to ${requester.advName} via ${requester.routeHopCount} hop(s)',
);
unawaited(() async {
final served = await imageProvider.serveSessionTo(
sessionId: imageFetchRequest.sessionId,
requester: requester,
requestedIndices: imageFetchRequest.want == 'missing'
? imageFetchRequest.missingIndices.toSet()
: null,
);
if (served) {
messagesProvider.recordMediaTransfer(
sessionId: imageFetchRequest.sessionId,
mediaType: 'image',
requesterKey6: imageFetchRequest.requesterKey6,
requesterName: requester.advName,
);
}
}());
return;
}
if (ImagePacket.isImageBinary(payload)) {
final frag = ImagePacket.tryParseBinary(payload);
if (frag == null) return;
debugPrint('📷 [AppProvider] Binary image fragment received: $frag');
final session = imageProvider.session(frag.sessionId);
if (session == null && frag.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact image fragment without envelope '
'for session ${frag.sessionId}',
);
return;
}
imageProvider.addFragment(
session == null
? frag
: ImagePacket(
sessionId: frag.sessionId,
format: session.format,
index: frag.index,
total: session.total,
data: frag.data,
),
width: session?.width ?? 0,
height: session?.height ?? 0,
);
_scheduleImageMissingRetry(
frag.sessionId,
justComplete: imageProvider.isComplete(frag.sessionId),
);
return;
}
if (!VoicePacket.isVoiceBinary(payload)) return;
final pkt = VoicePacket.tryParseBinary(payload);
if (pkt == null) return;
debugPrint('🎙️ [AppProvider] Binary voice packet received: $pkt');
final session = voiceProvider.session(pkt.sessionId);
if (session == null && pkt.total < 1) {
debugPrint(
'⚠️ [AppProvider] Dropping compact voice packet without envelope '
'for session ${pkt.sessionId}',
);
return;
}
final justComplete = voiceProvider.addPacket(
session == null
? pkt
: VoicePacket(
sessionId: pkt.sessionId,
mode: session.mode,
index: pkt.index,
total: session.total,
codec2Data: pkt.codec2Data,
),
);
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
// Insert or update the placeholder message in the chat list
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
};
connectionProvider.onControlDataReceived =
(payload, snrRaw, rssiDbm, pathLen) {
_handleControlDataDiscovery(
payload: payload,
snrRaw: snrRaw,
rssiDbm: rssiDbm,
pathLen: pathLen,
);
};
connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) {
final parsed = _tryParseRepeaterStatus(statusData);
if (parsed == null) {
return;
}
contactsProvider.updatePendingAdvertStatusByPrefix(
publicKeyPrefix,
batteryMv: parsed.batteryMv,
queueLen: parsed.queueLen,
lastRssi: parsed.lastRssi,
lastSnrRaw: parsed.lastSnrRaw,
uptimeSecs: parsed.uptimeSecs,
);
};
// When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) {
debugPrint(
'🔄 [AppProvider] Path updated for contact: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
if (connectionProvider.deviceInfo.isConnected) {
unawaited(connectionProvider.getContact(publicKey));
}
};
// When an advertisement is received (PUSH_CODE_ADVERT 0x80)
//
// Official app flow:
// 1. importReceivedAdvert(key) — import advert into firmware contact table
// 2. getContact(key) — check if contact existed before (for new-notification)
// 3. syncContact(key) — fetch from device + upsert into DB
// 4. getContact(key) — get fresh contact with name/type
// 5. upsert into discovered_contacts
// 6. if was new → notify with name + type
connectionProvider.onAdvertReceived = (publicKey) {
debugPrint(
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
unawaited(_retainAdvertRxPath(publicKey));
unawaited(_handlePushAdvert(publicKey));
};
// When firmware deletes a contact due to contacts table overflow (PUSH_CODE_CONTACT_DELETED 0x8F)
connectionProvider.onContactDeleted = (publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final contact = contactsProvider.findContactByKey(publicKey);
final name = contact?.advName ?? keyHex.substring(0, 12);
debugPrint('⚠️ [AppProvider] Contact deleted by firmware: $name');
contactsProvider.removeContact(keyHex);
messagesProvider.logSystemMessage(
text: 'Contact "$name" was removed — device contacts table is full',
level: 'warning',
);
};
// When firmware reports contacts storage is full (PUSH_CODE_CONTACTS_FULL 0x90)
connectionProvider.onContactsFull = () {
debugPrint('⚠️ [AppProvider] Contacts storage full');
messagesProvider.logSystemMessage(
text:
'Device contacts storage is full. New contacts will overwrite old ones.',
level: 'warning',
);
};
// When a message is sent (RESP_CODE_SENT received)
connectionProvider
.onMessageSent = (messageId, expectedAckTag, suggestedTimeoutMs) {
debugPrint(
'📤 [AppProvider] Message sent - Message ID: $messageId, ACK tag: $expectedAckTag',
);
messagesProvider.markMessageSent(
messageId,
expectedAckTag,
suggestedTimeoutMs,
);
};
// When a message is delivered (PUSH_CODE_SEND_CONFIRMED received)
connectionProvider.onMessageDelivered = (ackCode, roundTripTimeMs) {
debugPrint(
'✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
);
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
connectionProvider
.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
debugPrint(
'🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount',
);
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
connectionProvider.prepareDirectMessageSendCallback =
({required messageId, required contact, required retryAttempt}) async {
return _prepareDirectMessageSend(
messageId: messageId,
contact: contact,
);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback =
({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
return await connectionProvider.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(contactPublicKey);
};
// Reset path before the last retry attempt to force flood mode
messagesProvider.resetPathBeforeLastRetryCallback = (contact) async {
if (connectionProvider.deviceInfo.isConnected) {
debugPrint(
'🔄 [AppProvider] Resetting path for ${contact.advName} before last retry (flood fallback)',
);
await connectionProvider.resetPath(contact.publicKey);
}
};
messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback(
messageId: messageId,
contact: contact,
message: message,
);
};
messagesProvider.onFinalDirectMessageFailureCallback =
({required messageId, required contact, required message}) async {
await _handleDirectMessageFinalFailure(
messageId: messageId,
contact: contact,
);
};
messagesProvider.onDirectMessageDeliveredCallback =
({
required messageId,
required contact,
required message,
required roundTripTimeMs,
}) {
_handleDirectMessageDelivered(
messageId: messageId,
contact: contact,
roundTripTimeMs: roundTripTimeMs,
);
};
messagesProvider.onManualRetryPreparedCallback = (messageId) {
_directMessageRouteSessions.remove(messageId);
};
}
Future<Contact> _prepareDirectMessageSend({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId];
if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession(
currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
_directMessageRouteSessions[messageId] = session;
}
await _applyPathSelection(
latestContact,
session.currentSelection,
messageId: messageId,
routerFallbackAttempted: session.routerFallbackAttempted,
);
return contactsProvider.findContactByKey(contact.publicKey) ??
latestContact;
}
Future<void> _applyPathSelection(
Contact contact,
PathSelection selection, {
required String messageId,
required bool routerFallbackAttempted,
}) async {
final previousRoute = ContactRouteCodec.fromContact(contact);
try {
if (selection.usesFlood) {
contactsProvider.resetContactRouteLocal(contact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(contact.publicKey);
}
} else {
final pathDescriptor =
((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F);
final signedDescriptor = ContactRouteCodec.toSignedDescriptor(
pathDescriptor,
);
final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, selection.pathBytes.length, selection.pathBytes);
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
}
}
} catch (error) {
_restoreRouteLocal(contact.publicKey, previousRoute);
rethrow;
}
messagesProvider.updateMessageRouteSelection(
messageId,
selection,
routerFallbackAttempted: routerFallbackAttempted,
);
}
void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) {
if (route == null) {
contactsProvider.resetContactRouteLocal(publicKey);
return;
}
contactsProvider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
Future<void> _restoreRouteOnDevice(
Contact contact,
ParsedContactRoute? route,
) async {
_restoreRouteLocal(contact.publicKey, route);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (route == null) {
await connectionProvider.resetPath(contact.publicKey);
return;
}
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) {
return PathSelection(
mode: PathSelectionMode.nearestRouter,
pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)),
hopCount: 1,
hashSize: hashSize,
relayName: repeater.advName,
relayKey6: _key6(repeater.publicKey),
);
}
Future<bool> _sendWithFinalNearestRouterFallback({
required String messageId,
required Contact contact,
required Message message,
}) async {
if (!_nearestRelayFallbackEnabled) {
return false;
}
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session =
_directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession(
currentSelection: latestContact.routeHasPath
? PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: PathSelection.flood(),
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters,
recipient: latestContact,
);
if (repeater == null) {
return false;
}
final routeHashSize = await RouteHashPreferences.getHashSize();
final fallbackSelection = _buildNearestRouterSelection(
repeater,
routeHashSize,
);
_directMessageRouteSessions[messageId] = session.copyWith(
currentSelection: fallbackSelection,
routerFallbackAttempted: true,
);
messagesProvider.updateMessageRouteSelection(
messageId,
fallbackSelection,
routerFallbackAttempted: true,
);
return connectionProvider.sendTextMessage(
contactPublicKey: latestContact.publicKey,
text: message.text,
messageId: messageId,
contact: latestContact,
retryAttempt: message.retryAttempt + 1,
);
}
void _handleDirectMessageDelivered({
required String messageId,
required Contact contact,
required int roundTripTimeMs,
}) {
final session = _directMessageRouteSessions.remove(messageId);
if (session == null) {
return;
}
unawaited(
_pathHistoryService.recordPathResult(
contact.publicKeyHex,
session.currentSelection,
success: true,
roundTripTimeMs: roundTripTimeMs,
),
);
}
Future<void> _handleDirectMessageFinalFailure({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId);
if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute);
}
}
if (_clearPathOnMaxRetry) {
contactsProvider.resetContactRouteLocal(latestContact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(latestContact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.getContact(latestContact.publicKey);
}
}
}
}
String _key6(Uint8List publicKey) {
final bytes = publicKey.sublist(0, math.min(6, publicKey.length));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
/// Estimate contact location from the received message path.
///
/// When we receive a message, the path bytes describe how it traveled:
/// Sender → first_hop (near sender) → ... → last_hop (near us) → Us
///
/// The FIRST hop in the received path is the repeater nearest to the sender.
/// We don't have the actual RSSI between sender and that repeater, so we
/// use a conservative estimate to place them within typical LoRa range.
void _estimateContactLocationFromReceivedPath({
required Contact contact,
required List<int> receivedPathBytes,
}) {
if (receivedPathBytes.isEmpty) return;
// Infer hash size from the contact's known path encoding, or default to 1
final hashSize = contact.routeHasPath ? contact.routeHashSize : 1;
if (receivedPathBytes.length < hashSize) return;
// First hop in received path = repeater nearest to sender
final firstHopHash = receivedPathBytes
.sublist(0, hashSize)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
Contact? nearSenderRepeater;
for (final c in contactsProvider.contacts) {
if (c.publicKeyHex.toLowerCase().startsWith(firstHopHash) &&
c.displayLocation != null) {
nearSenderRepeater = c;
break;
}
}
if (nearSenderRepeater == null) return;
// We don't have the RSSI between sender and their repeater.
// Use -70 dBm as conservative estimate (~300m in open terrain).
const int estimatedRssi = -70;
contactsProvider.addRssiObservation(
contactPublicKeyHex: contact.publicKeyHex,
contactPublicKey: contact.publicKey,
observation: RssiObservation(
repeaterLocation: nearSenderRepeater.displayLocation!,
rssiDbm: estimatedRssi,
observedAt: DateTime.now(),
),
);
}
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) {
return;
}
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse(
reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize,
);
contactsProvider.retainReceivedRoute(
publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
}
/// Handle pushAdvert (0x80) — matches the official MeshCore app flow:
///
/// 1. importReceivedAdvert(key) — import advert into firmware contact table
/// 2. Check if contact was already known locally (for new-notification)
/// 3. getContact(key) — fetch full contact from device (now stored after import)
/// → flows through onContactReceivedDetailed which upserts + notifies
Future<void> _handlePushAdvert(Uint8List publicKey) async {
// Step 1: Import the advert into firmware's contact table.
// This tells the firmware to store the contact from its received-advert
// buffer, making subsequent getContact return name/type/location.
await connectionProvider.importReceivedAdvert(publicKey);
// Step 2: Check if this was an already-known contact before sync
final wasKnown = contactsProvider.findContactByKey(publicKey) != null;
// Step 3: Fetch the full contact from device (import made it available).
// This flows through onContactReceivedDetailed, which handles:
// - upserting into contacts or pending adverts
// - showing discovery notification for new contacts
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.getContact(publicKey);
}
// For existing contacts, the getContact above already updated them.
// For new contacts from adverts, onContactReceivedDetailed (source=advert)
// already added to pending adverts and showed notification.
// Nothing else to do — the callback pipeline handles everything.
if (wasKnown) {
debugPrint(
' [pushAdvert] Existing contact refreshed',
);
}
}
Future<void> _requestRepeaterStatus(Uint8List publicKey) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final keyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final now = DateTime.now();
final lastRequestAt = _recentRepeaterStatusRequests[keyHex];
if (lastRequestAt != null &&
now.difference(lastRequestAt) < const Duration(minutes: 2)) {
return;
}
_recentRepeaterStatusRequests[keyHex] = now;
try {
await connectionProvider.requestStatus(publicKey);
} catch (_) {
// Ignore unsupported or unreachable repeaters.
}
}
Future<void> _maybeRequestRepeaterOwnerInfo(Uint8List publicKey) async {
final pendingAdvert = contactsProvider.pendingAdvertByKey(publicKey);
if (pendingAdvert == null ||
pendingAdvert.typeValue != ContactType.repeater.value ||
pendingAdvert.advName?.trim().isNotEmpty == true ||
!connectionProvider.deviceInfo.isConnected) {
return;
}
final keyHex = pendingAdvert.publicKeyHex;
final now = DateTime.now();
final lastRequestAt = _recentRepeaterOwnerInfoRequests[keyHex];
if (lastRequestAt != null &&
now.difference(lastRequestAt) < _repeaterOwnerInfoRequestCooldown) {
return;
}
Contact? existingContact;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == keyHex) {
existingContact = contact;
break;
}
}
var temporaryContactAdded = false;
final requestContact =
existingContact ?? _temporaryRepeaterContactForOwnerInfo(pendingAdvert);
if (requestContact == null || !requestContact.routeHasPath) {
return;
}
_recentRepeaterOwnerInfoRequests[keyHex] = now;
if (existingContact == null) {
connectionProvider.clearError();
await connectionProvider.addOrUpdateContact(requestContact);
if (connectionProvider.error != null) {
return;
}
connectionProvider.clearError();
temporaryContactAdded = true;
}
try {
final ticket = await connectionProvider.sendAnonRequest(
contactPublicKey: publicKey,
requestData: _buildRepeaterOwnerRequest(requestContact),
);
if (ticket == null) {
return;
}
_pendingRepeaterOwnerRequests[ticket.tag] = _PendingRepeaterOwnerRequest(
publicKey: Uint8List.fromList(publicKey),
);
Future.delayed(
Duration(milliseconds: ticket.suggestedTimeoutMs + 1500),
() => _pendingRepeaterOwnerRequests.remove(ticket.tag),
);
} catch (_) {
// Ignore unsupported or unreachable repeaters.
} finally {
if (temporaryContactAdded) {
Future.delayed(const Duration(milliseconds: 250), () async {
await connectionProvider.removeContact(publicKey);
connectionProvider.clearError();
});
}
}
}
Contact? _temporaryRepeaterContactForOwnerInfo(PendingAdvert advert) {
final signedEncodedPathLen = advert.signedEncodedPathLen;
if (signedEncodedPathLen == null) {
return null;
}
final advName = advert.advName?.trim();
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.repeater,
flags: advert.flags ?? 0,
outPathLen: signedEncodedPathLen,
outPath: advert.paddedPathBytes == null
? Uint8List(ContactRouteCodec.maxPathBytes)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName?.isNotEmpty == true ? advName! : advert.shortDisplayKey,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
Uint8List _buildRepeaterOwnerRequest(Contact contact) {
final replyPathDescriptor = contact.routeEncodedPathLen;
final replyPathBytes = contact.routeHopCount > 0
? Uint8List.fromList(
LogRxRouteDecoder.reverseHopBytes(
contact.routePathBytes,
hashSize: contact.routeHashSize,
),
)
: Uint8List(0);
return Uint8List.fromList([
_anonReqTypeOwner,
replyPathDescriptor,
...replyPathBytes,
]);
}
bool _handlePendingRepeaterOwnerResponse(int tag, Uint8List responseData) {
final request = _pendingRepeaterOwnerRequests.remove(tag);
if (request == null) {
return false;
}
final ownerName = _tryParseRepeaterOwnerName(responseData);
if (ownerName == null || ownerName.isEmpty) {
return true;
}
final existing = contactsProvider.pendingAdvertByKey(request.publicKey);
contactsProvider.addOrUpdatePendingAdvertMetadata(
publicKey: request.publicKey,
typeValue: existing?.typeValue ?? ContactType.repeater.value,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
advName: ownerName,
);
return true;
}
String? _tryParseRepeaterOwnerName(Uint8List responseData) {
if (responseData.length <= 4) {
return null;
}
try {
final payload = utf8
.decode(responseData.sublist(4), allowMalformed: true)
.trim();
if (payload.isEmpty) {
return null;
}
final firstLine = payload.split(RegExp(r'[\r\n]+')).first.trim();
return firstLine.isEmpty ? null : firstLine;
} catch (_) {
return null;
}
}
_ParsedRepeaterStatus? _tryParseRepeaterStatus(Uint8List statusData) {
if (statusData.length < 52) {
return null;
}
try {
final data = ByteData.sublistView(statusData);
var offset = 0;
final batteryMv = data.getUint16(offset, Endian.little);
offset += 2;
final queueLen = data.getUint16(offset, Endian.little);
offset += 2;
offset += 2; // noiseFloor
final lastRssi = data.getInt16(offset, Endian.little);
offset += 2;
offset += 4; // packetsRecv
offset += 4; // packetsSent
offset += 4; // txAirSecs
final uptimeSecs = data.getUint32(offset, Endian.little);
offset += 4;
offset += 4; // floodTx
offset += 4; // directTx
offset += 4; // floodRx
offset += 4; // directRx
offset += 2; // errEvents
final lastSnrRaw = data.getInt16(offset, Endian.little);
return _ParsedRepeaterStatus(
batteryMv: batteryMv,
queueLen: queueLen,
lastRssi: lastRssi,
lastSnrRaw: lastSnrRaw,
uptimeSecs: uptimeSecs,
);
} catch (_) {
return null;
}
}
_ParsedRawAdvert? _tryParseRawAdvert(Uint8List rawPayload) {
if (rawPayload.length < 103) {
return null;
}
try {
final reader = BufferReader(rawPayload);
final header = reader.readByte();
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
if (payloadType != _rawPayloadTypeAdvert) {
return null;
}
if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) {
if (reader.remainingBytesCount < 4) {
return null;
}
reader.skip(4);
}
if (reader.remainingBytesCount < 1) {
return null;
}
final pathByteLen = reader.readByte();
if (reader.remainingBytesCount < pathByteLen + 101) {
return null;
}
final pathBytes = reader.readBytes(pathByteLen);
final publicKey = reader.readBytes(32);
final timestamp = reader.readInt32LE();
reader.skip(64);
final flags = reader.readByte();
final typeValue = flags & 0x0F;
final hasLocation = (flags & 0x10) != 0;
final hasName = (flags & 0x80) != 0;
int? advLat;
int? advLon;
if (hasLocation) {
if (reader.remainingBytesCount < 8) {
return null;
}
advLat = reader.readInt32LE();
advLon = reader.readInt32LE();
}
String? advName;
if (hasName && reader.remainingBytesCount > 0) {
final decodedName = utf8
.decode(reader.readRemainingBytes(), allowMalformed: true)
.trim();
if (decodedName.isNotEmpty) {
advName = decodedName;
}
}
int? signedEncodedPathLen;
Uint8List? paddedPathBytes;
if (pathBytes.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(pathBytes);
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final padded = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, reversedPathBytes.length, reversedPathBytes);
final encodedPathLen =
((hashSize - 1) << 6) |
((reversedPathBytes.length ~/ hashSize) & 0x3F);
signedEncodedPathLen = ContactRouteCodec.toSignedDescriptor(
encodedPathLen,
);
paddedPathBytes = padded;
}
return _ParsedRawAdvert(
publicKey: publicKey,
advName: advName,
typeValue: typeValue,
flags: flags,
lastAdvert: timestamp,
advLat: advLat,
advLon: advLon,
signedEncodedPathLen: signedEncodedPathLen,
paddedPathBytes: paddedPathBytes,
);
} catch (_) {
return null;
}
}
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
// Note: Device clock is automatically synced during connection in MeshCoreBleService
// No need to sync it again here
// Get battery and storage information
await connectionProvider.getBatteryAndStorage();
// Load contacts (waits for device to finish sending all contacts)
await connectionProvider.getContacts();
// Sync all channels so slot assignment and channel state mirror the device.
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
debugPrint(
'📻 [AppProvider] Syncing channels (max: ${channelsToSync ?? 40})...',
);
await connectionProvider.syncChannels(maxChannels: channelsToSync);
debugPrint('✅ [AppProvider] Channel sync complete');
// Automatically login to all saved rooms
await _autoLoginToRooms();
// FALLBACK: Sync messages once after connection to catch any missed push notifications
// This handles the case where messages arrived while the app was disconnected
debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
);
final initialMessageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
);
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
// Start location tracking AFTER all initialization is complete
debugPrint(
'📍 [AppProvider] Starting location tracking after successful initialization',
);
await _startLocationTracking();
// Sync drawing messages with DrawingProvider
// This restores any drawings that may be missing from storage
debugPrint(
'🎨 [AppProvider] Syncing drawing messages with DrawingProvider...',
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
Future<void> _syncAfterReconnect({bool started = false}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try {
debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
}
}
/// Automatically login to all rooms with saved passwords on cold connect
Future<void> _autoLoginToRooms() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Get all room contacts (excluding Public Channel)
final rooms = contactsProvider.rooms
.where((room) => !room.isPublicChannel)
.toList();
if (rooms.isEmpty) {
debugPrint('📂 [AppProvider] No rooms found to auto-login');
return;
}
debugPrint(
'📂 [AppProvider] Found ${rooms.length} room(s), attempting auto-login...',
);
final prefs = await SharedPreferences.getInstance();
for (final room in rooms) {
try {
// Load saved password for this room
final roomKey = 'room_password_${room.publicKeyHex}';
final savedPassword = prefs.getString(roomKey) ?? 'hello';
debugPrint(
'🔑 [AppProvider] Auto-logging into room: ${room.advName}',
);
// Set up one-time callbacks for this room login
await _loginToRoomWithCallback(room, savedPassword);
// Small delay between logins to avoid overwhelming the device
await Future.delayed(const Duration(milliseconds: 300));
} catch (e) {
debugPrint(
'❌ [AppProvider] Failed to auto-login to ${room.advName}: $e',
);
}
}
} catch (e) {
debugPrint('❌ [AppProvider] Auto-login error: $e');
}
}
/// Login to a specific room with callback handling
Future<void> _loginToRoomWithCallback(Contact room, String password) async {
// Create a completer to wait for login result
final completer = Completer<bool>();
// Store original callbacks
final originalOnSuccess = connectionProvider.onLoginSuccess;
final originalOnFail = connectionProvider.onLoginFail;
// Set up temporary callbacks
connectionProvider
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('✅ [AppProvider] Auto-login successful for ${room.advName}');
debugPrint(
'📡 [AppProvider] Room server will push messages automatically via PUSH_CODE_MSG_WAITING',
);
completer.complete(true);
};
connectionProvider.onLoginFail = (publicKeyPrefix) {
// Restore original callbacks
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint(
'❌ [AppProvider] Auto-login failed for ${room.advName} (incorrect password)',
);
completer.complete(false);
};
try {
// Send login request
await connectionProvider.loginToRoom(
roomPublicKey: room.publicKey,
password: password,
);
// Wait for login result with timeout
await completer.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
// Restore callbacks on timeout
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint('⏱️ [AppProvider] Auto-login timeout for ${room.advName}');
return false;
},
);
} catch (e) {
// Restore callbacks on error
connectionProvider.onLoginSuccess = originalOnSuccess;
connectionProvider.onLoginFail = originalOnFail;
debugPrint(
'❌ [AppProvider] Error during auto-login to ${room.advName}: $e',
);
}
}
Contact? _resolveContactByPrefixHex(String prefixHex) {
if (prefixHex.length != 12) return null;
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
}
void setFastLocationUiActive(bool isActive) {
if (_fastLocationScreenActive == isActive) return;
_fastLocationScreenActive = isActive;
locationTrackingService.setFastLocationActiveUse(isActive);
}
Future<void> reloadProfileScopedSettings() async {
await Future.wait([
_loadMapEnabled(),
_loadContactsEnabled(),
_loadSensorsEnabled(),
_loadVoiceSilenceTrimmingEnabled(),
_loadVoiceBandPassFilterEnabled(),
_loadVoiceCompressorEnabled(),
_loadVoiceLimiterEnabled(),
_loadVoiceAutoGainEnabled(),
_loadVoiceEchoCancellationEnabled(),
_loadVoiceNoiseSuppressionEnabled(),
_loadMessageFontScale(),
_loadMessagingRouteSettings(),
locationTrackingService.loadSettings(),
]);
}
Future<void> _sendFastLocationUpdate(
dynamic position, {
required String reason,
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final publicKey = connectionProvider.deviceInfo.publicKey;
if (publicKey == null || publicKey.length < 6) {
return;
}
final senderKey6 = publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final packet = FastGpsPacket(
senderKey6: senderKey6,
latitude: position.latitude as double,
longitude: position.longitude as double,
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
debugPrint(
'📍 [AppProvider] Sending fast GPS update ($reason): '
'${position.latitude}, ${position.longitude}',
);
try {
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
} catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
}
}
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {
return liveContact;
}
return _resolveRequesterFromSentMessages(
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
tryParseEnvelope: VoiceEnvelope.tryParseText,
mediaLabel: 'voice',
);
}
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
if (liveContact != null) {
return liveContact;
}
return _resolveRequesterFromSentMessages(
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
tryParseEnvelope: ImageEnvelope.tryParse,
mediaLabel: 'image',
);
}
Contact? _resolveRequesterFromSentMessages<T>({
required String sessionId,
required String requesterKey6,
required T? Function(String text) tryParseEnvelope,
required String mediaLabel,
}) {
for (final message in messagesProvider.messages.reversed) {
final envelope = tryParseEnvelope(message.text);
if (envelope == null) {
continue;
}
final envelopeSessionId = switch (envelope) {
VoiceEnvelope voiceEnvelope => voiceEnvelope.sessionId,
ImageEnvelope imageEnvelope => imageEnvelope.sessionId,
_ => null,
};
if (envelopeSessionId != sessionId) {
continue;
}
final recipientKey = message.recipientPublicKey;
if (recipientKey == null || recipientKey.isEmpty) {
continue;
}
final recipient = contactsProvider.findContactByKey(recipientKey);
if (recipient == null) {
continue;
}
final recipientKey6 = recipient.publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (recipientKey6 != requesterKey6) {
continue;
}
debugPrint(
'${mediaLabel == 'voice' ? '🎙️' : '📷'} [AppProvider] Resolved '
'$mediaLabel requester from sent message metadata for session '
'$sessionId: ${recipient.advName}',
);
return recipient;
}
return null;
}
String _mediaSwarmKey(String mediaType, String sessionId) =>
'$mediaType:$sessionId';
String? _deviceKey6Hex() {
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return null;
}
return deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
List<int> _availableIndicesForSession(String mediaType, String sessionId) {
return switch (mediaType) {
'voice' => voiceProvider.availablePacketIndices(sessionId),
'image' => imageProvider.availableFragmentIndices(sessionId),
_ => const <int>[],
};
}
List<int> _matchingAvailableIndices(MediaSwarmRequest request) {
final available = _availableIndicesForSession(
request.mediaType,
request.sessionId,
);
if (available.isEmpty) return const [];
if (request.requestsAll) return available;
final requested = request.missingIndices.toSet();
return available.where(requested.contains).toList()..sort();
}
List<Contact> _eligibleSwarmPeers({String? excludeKey6}) {
final ownKey6 = _deviceKey6Hex();
return contactsProvider.contacts.where((contact) {
if (!contact.routeHasPath ||
contact.routeHopCount > _maxDirectPayloadHops ||
!contact.routeSupportsLegacyRawTransport ||
contact.outPath.isEmpty ||
contact.publicKey.length < 6) {
return false;
}
final key6 = contact.publicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (key6 == ownKey6 || key6 == excludeKey6) {
return false;
}
return true;
}).toList();
}
void _handleIncomingMediaSwarmRequest(MediaSwarmRequest request) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || request.requesterKey6 == ownKey6) {
return;
}
final available = _matchingAvailableIndices(request);
if (available.isEmpty) {
return;
}
final availability = MediaSwarmAvailability(
mediaType: request.mediaType,
sessionId: request.sessionId,
requesterKey6: request.requesterKey6,
responderKey6: ownKey6,
availableIndices: available,
);
debugPrint(
'🌐 [AppProvider] Media swarm availability for ${request.mediaType} '
'${request.sessionId}: ${available.length} fragment(s)',
);
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null ||
!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops ||
requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeEncodedPathLen,
payload: availability.encodeBinary(),
),
);
}
void _handleIncomingMediaSwarmAvailability(
MediaSwarmAvailability availability,
) {
final ownKey6 = _deviceKey6Hex();
if (ownKey6 == null || availability.requesterKey6 != ownKey6) {
return;
}
final key = _mediaSwarmKey(availability.mediaType, availability.sessionId);
final responses = _pendingMediaSwarmResponses[key];
if (responses == null) {
return;
}
responses[availability.responderKey6] = availability;
debugPrint(
'🌐 [AppProvider] Media swarm response for ${availability.mediaType} '
'${availability.sessionId} from ${availability.responderKey6} '
'(${availability.servesAll ? 'all' : availability.availableIndices.length})',
);
}
Future<bool> _requestMissingMediaViaSwarm({
required String mediaType,
required String sessionId,
required List<int> missingIndices,
required String? originalSenderKey6,
}) async {
if (!connectionProvider.deviceInfo.isConnected || missingIndices.isEmpty) {
return false;
}
final key = _mediaSwarmKey(mediaType, sessionId);
final pending = _pendingMediaSwarmFetches[key];
if (pending != null) {
return pending;
}
final requesterKey6 = _deviceKey6Hex();
if (requesterKey6 == null) {
return false;
}
final future = () async {
final responses = <String, MediaSwarmAvailability>{};
_pendingMediaSwarmResponses[key] = responses;
try {
final request = MediaSwarmRequest(
mediaType: mediaType,
sessionId: sessionId,
requesterKey6: requesterKey6,
missingIndices: missingIndices,
);
final peers = _eligibleSwarmPeers(excludeKey6: originalSenderKey6);
if (peers.isEmpty) {
return false;
}
debugPrint(
'🌐 [AppProvider] Media swarm request for $mediaType $sessionId '
'(${missingIndices.length} needed fragment(s), ${peers.length} peer(s))',
);
for (final peer in peers) {
await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath,
contactPathLen: peer.routeEncodedPathLen,
payload: request.encodeBinary(),
);
}
await Future<void>.delayed(_mediaSwarmResponseWindow);
final orderedResponses =
responses.values
.where(
(response) => response.responderKey6 != originalSenderKey6,
)
.toList()
..sort((a, b) {
final aScore = _swarmResponseScore(a, missingIndices);
final bScore = _swarmResponseScore(b, missingIndices);
return bScore.compareTo(aScore);
});
for (final response in orderedResponses) {
final responder = _resolveContactByPrefixHex(response.responderKey6);
if (responder == null ||
!responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops ||
responder.outPath.isEmpty) {
continue;
}
final requestedSubset = response.servesAll
? missingIndices
: missingIndices
.where(response.availableIndices.toSet().contains)
.toList();
if (requestedSubset.isEmpty) {
continue;
}
final requestedSet = requestedSubset.toSet();
final sent = await _sendDirectMediaFetchRequest(
mediaType: mediaType,
sessionId: sessionId,
target: responder,
requesterKey6: requesterKey6,
missingIndices: requestedSet,
);
if (sent) {
debugPrint(
'🌐 [AppProvider] Requested $mediaType $sessionId '
'from swarm peer ${responder.advName} '
'(${requestedSubset.length} fragment(s))',
);
return true;
}
}
return false;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Media swarm request failed for $mediaType '
'$sessionId: $e',
);
return false;
} finally {
_pendingMediaSwarmResponses.remove(key);
}
}();
_pendingMediaSwarmFetches[key] = future;
try {
return await future;
} finally {
_pendingMediaSwarmFetches.remove(key);
}
}
int _swarmResponseScore(
MediaSwarmAvailability response,
List<int> missingIndices,
) {
if (response.servesAll) {
return missingIndices.length;
}
final needed = missingIndices.toSet();
return response.availableIndices.where(needed.contains).length;
}
Future<bool> _sendDirectMediaFetchRequest({
required String mediaType,
required String sessionId,
required Contact target,
required String requesterKey6,
required Set<int> missingIndices,
}) async {
try {
final payload = switch (mediaType) {
'voice' => VoiceFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
'image' => ImageFetchRequest(
sessionId: sessionId,
want: missingIndices.isEmpty ? 'all' : 'missing',
missingIndices: missingIndices.toList()..sort(),
requesterKey6: requesterKey6,
).encodeBinary(),
_ => null,
};
if (payload == null) {
return false;
}
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeEncodedPathLen,
payload: payload,
);
return true;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Direct $mediaType fetch via ${target.advName} failed: $e',
);
return false;
}
}
void _scheduleVoiceMissingRetry(
String sessionId, {
required bool justComplete,
}) {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (justComplete || voiceProvider.isComplete(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
_voiceMissingRetryAttempts[sessionId] = 0;
_voiceMissingRetryTimers[sessionId]?.cancel();
_voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingVoicePackets(sessionId));
});
}
void _scheduleImageMissingRetry(
String sessionId, {
required bool justComplete,
}) {
if (imageProvider.isReceiveCanceled(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
if (justComplete || imageProvider.isComplete(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
_imageMissingRetryAttempts[sessionId] = 0;
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
void _clearVoiceMissingRetry(String sessionId) {
_voiceMissingRetryTimers.remove(sessionId)?.cancel();
_voiceMissingRetryAttempts.remove(sessionId);
}
void _clearImageMissingRetry(String sessionId) {
_imageMissingRetryTimers.remove(sessionId)?.cancel();
_imageMissingRetryAttempts.remove(sessionId);
}
Future<void> _requestMissingVoicePackets(String sessionId) async {
if (voiceProvider.isReceiveCanceled(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
if (voiceProvider.isComplete(sessionId)) {
_clearVoiceMissingRetry(sessionId);
return;
}
final attempt = _voiceMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxPacketRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Voice re-request limit reached for $sessionId',
);
_clearVoiceMissingRetry(sessionId);
return;
}
final senderKey6 = _voiceSessionSenderKey6[sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
final requesterKey6 = _deviceKey6Hex();
if (requesterKey6 == null) return;
final missing = voiceProvider.missingPacketIndices(sessionId);
if (missing.isEmpty) {
_clearVoiceMissingRetry(sessionId);
return;
}
var sent = false;
if (sender != null) {
final routeOk = await verifyRawTransportRoute(sender);
if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
mediaType: 'voice',
sessionId: sessionId,
target: sender,
requesterKey6: requesterKey6,
missingIndices: missing.toSet(),
);
}
}
if (!sent) {
sent = await _requestMissingMediaViaSwarm(
mediaType: 'voice',
sessionId: sessionId,
missingIndices: missing,
originalSenderKey6: senderKey6,
);
}
if (!sent) {
return;
}
_voiceMissingRetryAttempts[sessionId] = attempt + 1;
_voiceMissingRetryTimers[sessionId]?.cancel();
_voiceMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingVoicePackets(sessionId));
});
}
Future<void> _requestMissingImageFragments(String sessionId) async {
if (imageProvider.isReceiveCanceled(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
if (imageProvider.isComplete(sessionId)) {
_clearImageMissingRetry(sessionId);
return;
}
final attempt = _imageMissingRetryAttempts[sessionId] ?? 0;
if (attempt >= _maxPacketRetryAttempts) {
debugPrint(
'⚠️ [AppProvider] Image re-request limit reached for $sessionId',
);
_clearImageMissingRetry(sessionId);
return;
}
final senderKey6 = _imageSessionSenderKey6[sessionId];
if (senderKey6 == null) return;
final sender = _resolveContactByPrefixHex(senderKey6);
final requesterKey6 = _deviceKey6Hex();
if (requesterKey6 == null) return;
final missing = imageProvider.missingFragmentIndices(sessionId);
if (missing.isEmpty) {
_clearImageMissingRetry(sessionId);
return;
}
var sent = false;
if (sender != null) {
final routeOk = await verifyRawTransportRoute(sender);
if (routeOk) {
sent = await _sendDirectMediaFetchRequest(
mediaType: 'image',
sessionId: sessionId,
target: sender,
requesterKey6: requesterKey6,
missingIndices: missing.toSet(),
);
}
}
if (!sent) {
sent = await _requestMissingMediaViaSwarm(
mediaType: 'image',
sessionId: sessionId,
missingIndices: missing,
originalSenderKey6: senderKey6,
);
}
if (!sent) {
return;
}
_imageMissingRetryAttempts[sessionId] = attempt + 1;
_imageMissingRetryTimers[sessionId]?.cancel();
_imageMissingRetryTimers[sessionId] = Timer(_packetRetryDelay, () {
unawaited(_requestMissingImageFragments(sessionId));
});
}
/// Insert or update a voice placeholder message for binary raw-data packets.
///
/// Binary voice packets arrive without a chat message, so we synthesise one
/// to give the user a playable bubble in the message list.
void _handleIncomingVoicePacket(
VoicePacket pkt, {
required bool justComplete,
}) {
final sessionId = pkt.sessionId;
// Check if a placeholder for this session already exists
final existing = messagesProvider.messages
.where((m) => m.isVoice && m.voiceId == sessionId)
.firstOrNull;
if (existing != null) {
// Already have a placeholder — no need to add another
return;
}
// First packet of a new session: insert placeholder message
final msgId = 'voice_$sessionId';
final placeholder = Message(
id: msgId,
messageType: MessageType.contact, // direct contact (binary only)
senderPublicKeyPrefix: null,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
// Persist the first real packet in legacy V: text form so UI/debug paths
// can reconstruct packet metadata from actual data.
text: pkt.encodeText(),
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.received,
isVoice: true,
voiceId: sessionId,
);
messagesProvider.addMessage(placeholder, contactLookup: (_) => '');
}
String _rawProbeKey(int nonce) =>
nonce.toRadixString(16).padLeft(8, '0').toLowerCase();
Future<bool> verifyRawTransportRoute(
Contact target, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return false;
}
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false;
}
if (target.outPath.isEmpty) {
return false;
}
final probeKey = _routeProbeTargetKey(target);
final pendingProbe = _pendingRawRouteProbes[probeKey];
if (pendingProbe != null) {
return pendingProbe;
}
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
return false;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final future = () async {
final nonce = math.Random.secure().nextInt(0x100000000);
final ackFuture = _rawProbeWaiters.waitFor(
_rawProbeKey(nonce),
timeout: timeout,
);
try {
debugPrint(
'📡 [AppProvider] Outgoing raw route probe: target=${target.advName} hops=${target.routeHopCount} nonce=${nonce.toRadixString(16)}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeEncodedPathLen,
payload: RawRouteProbeRequest(
nonce: nonce,
requesterKey6: requesterKey6,
).encodeBinary(),
);
return await ackFuture;
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Raw route probe failed for ${target.advName}: $e',
);
_rawProbeWaiters.complete(_rawProbeKey(nonce));
return false;
}
}();
_pendingRawRouteProbes[probeKey] = future;
try {
return await future;
} finally {
_pendingRawRouteProbes.remove(probeKey);
}
}
String _routeProbeTargetKey(Contact target) {
if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}';
}
return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
final requester = _resolveContactByPrefixHex(request.requesterKey6);
if (requester == null) {
debugPrint(
'⚠️ [AppProvider] Raw route probe requester not found: ${request.requesterKey6}',
);
return;
}
if (!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops) {
debugPrint(
'⚠️ [AppProvider] Raw route probe requester out of range: ${requester.routeHopCount}',
);
return;
}
if (requester.outPath.isEmpty) {
return;
}
debugPrint(
'📡 [AppProvider] Outgoing raw route probe ACK: requester=${requester.advName} hops=${requester.routeHopCount} nonce=${request.nonce.toRadixString(16)}',
);
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeEncodedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
),
);
}
void _completeRawRouteProbeAck(int nonce) {
_rawProbeWaiters.complete(_rawProbeKey(nonce));
}
MessageReceptionDetails? _buildReceptionDetailsSnapshot(Message message) {
final matchedRxLog = _findBestMatchingRxLog(message);
final estimatedTx = estimateMessageTransmitDuration(
message,
radioBw: connectionProvider.deviceInfo.radioBw,
radioSf: connectionProvider.deviceInfo.radioSf,
radioCr: connectionProvider.deviceInfo.radioCr,
);
final senderToReceiptMs = _senderToReceiptMs(message);
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: estimatedTx > Duration.zero
? estimatedTx.inMilliseconds
: null,
senderToReceiptMs: senderToReceiptMs,
);
final postTransmitDelayMs =
senderToReceiptMs != null && estimatedTransmitMs != null
? (senderToReceiptMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
if (matchedRxLog == null &&
senderToReceiptMs == null &&
estimatedTransmitMs == null) {
return null;
}
return MessageReceptionDetails(
capturedAt: DateTime.now(),
packetLoggedAt: matchedRxLog?.timestamp,
rssiDbm: matchedRxLog?.logRxDataInfo?.rssiDbm,
snrDb: matchedRxLog?.logRxDataInfo?.snrDb,
pathBytes: _extractPathBytesFromLog(matchedRxLog),
senderToReceiptMs: senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs,
);
}
int? _senderToReceiptMs(Message message) {
if (message.senderTimestamp <= 0) return null;
final senderAt = DateTime.fromMillisecondsSinceEpoch(
message.senderTimestamp * 1000,
isUtc: true,
);
final deltaMs = message.receivedAt
.toUtc()
.difference(senderAt)
.inMilliseconds;
if (deltaMs < 0 || deltaMs > 86400000) return null;
return deltaMs;
}
BlePacketLog? _findBestMatchingRxLog(Message message) {
if (message.pathLen < 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
}
}
if (bestDeltaMs > 30000) return null;
return bestLog;
}
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
final publicKeyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
DecodedLogRxRoute? bestRoute;
var bestDeltaMs = 999999999;
final now = DateTime.now();
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != 0x04) continue;
final senderHash = decoded.originalSenderHashHex;
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestRoute = decoded;
}
}
if (bestDeltaMs > 30000) return null;
return bestRoute;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
// The ConnectionProvider's onMessageWaiting callback handles automatic message fetching
/// Refresh data (contacts and channels - messages are handled via events)
Future<void> refresh() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Sync contacts
await connectionProvider.getContacts();
// Sync all channels so refresh reflects the full device state.
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
notifyListeners();
} catch (e) {
debugPrint('Refresh error: $e');
}
}
/// Manually sync messages (only for explicit user pull-to-refresh)
/// Note: Messages are automatically synced via PUSH_CODE_MSG_WAITING events
/// This method should ONLY be called when the user explicitly pulls to refresh
Future<int> syncMessages() async {
if (!connectionProvider.deviceInfo.isConnected) return 0;
try {
debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)',
);
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages',
);
notifyListeners();
return messageCount;
} catch (e) {
debugPrint('❌ [AppProvider] Message sync error: $e');
return 0;
}
}
/// Handle connection state changes to manage location tracking
void _handleConnectionStateChange() {
final isConnected = connectionProvider.deviceInfo.isConnected;
final wasConnected = _wasDeviceConnected;
_wasDeviceConnected = isConnected;
final wasTracking = locationTrackingService.isTracking;
// Only stop tracking on disconnect - DON'T start on connect
// Location tracking will be started AFTER initialization completes
if (!isConnected && wasTracking) {
// Connection lost - stop location tracking
debugPrint(
'🔴 [AppProvider] BLE disconnected - stopping location tracking',
);
_stopLocationTracking();
}
if (!isConnected) {
connectionProvider.clearPendingAutomaticMessageSync();
}
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
}
/// Start location tracking
Future<void> _startLocationTracking() async {
try {
final started = await locationTrackingService.startTracking();
if (started) {
debugPrint('✅ [AppProvider] Location tracking started successfully');
} else {
debugPrint('⚠️ [AppProvider] Failed to start location tracking');
}
} catch (e) {
debugPrint('❌ [AppProvider] Error starting location tracking: $e');
}
}
/// Stop location tracking
Future<void> _stopLocationTracking() async {
try {
await locationTrackingService.stopTracking();
debugPrint('✅ [AppProvider] Location tracking stopped');
} catch (e) {
debugPrint('❌ [AppProvider] Error stopping location tracking: $e');
}
}
/// Clear all data
void clearAllData() {
contactsProvider.clearContacts();
messagesProvider.clearAll();
unawaited(voiceProvider.clearStoredVoiceData());
unawaited(imageProvider.clearAll());
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
_voiceMissingRetryTimers.clear();
_voiceMissingRetryAttempts.clear();
_imageMissingRetryTimers.clear();
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
_lowBatteryNotifiedNodeIds.clear();
notifyListeners();
}
void _handleControlDataDiscovery({
required Uint8List payload,
required int snrRaw,
required int rssiDbm,
required int pathLen,
}) {
const int controlTypeMask = 0xF0;
const int controlTypeNodeDiscoverResp = 0x90;
const int minFullDiscoverResponseLength = 6 + 32;
if (payload.length < minFullDiscoverResponseLength) {
return;
}
final controlType = payload[0] & controlTypeMask;
if (controlType != controlTypeNodeDiscoverResp) {
return;
}
final nodeType = payload[0] & 0x0F;
final publicKey = Uint8List.fromList(payload.sublist(6, 38));
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertMetadata(
publicKey: publicKey,
typeValue: nodeType,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
rxRssiDbm: rssiDbm,
rxSnrRaw: snrRaw,
signedEncodedPathLen: pathLen == 0 ? 0 : null,
paddedPathBytes: pathLen == 0
? Uint8List(ContactRouteCodec.maxPathBytes)
: null,
);
debugPrint(
'🛰️ [AppProvider] Control discovery response: type=$nodeType '
'pathLen=$pathLen snr=$snrRaw rssi=$rssiDbm new=$isNewPendingAdvert',
);
// Official app flow for controlData discovery:
// - Try getContact from firmware. If not found → skip.
// - If found → update path, request region names for repeaters.
// - Discovery list shows generic labels ("Repeater XX:YY") for unknown nodes.
// - Names appear later when the node's advert arrives via pushAdvert/pushNewAdvert.
if (isNewPendingAdvert) {
final keyHex = publicKey
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: keyHex,
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
),
);
}
if (nodeType == ContactType.repeater.value) {
unawaited(_requestRepeaterStatus(publicKey));
unawaited(_maybeRequestRepeaterOwnerInfo(publicKey));
}
// Try to enrich from firmware's existing contacts (no import — the
// controlData discovery response doesn't store an advert in firmware).
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
unawaited(connectionProvider.previewContact(publicKey));
}
}
/// Get app statistics
Map<String, dynamic> get statistics {
return {
'connection': {
'isConnected': connectionProvider.deviceInfo.isConnected,
'deviceName': connectionProvider.deviceInfo.deviceName,
'battery': connectionProvider.deviceInfo.batteryPercent,
},
'contacts': contactsProvider.contactCounts,
'messages': messagesProvider.messageStats,
'sarMarkers': messagesProvider.sarMarkerStats,
};
}
@override
void dispose() {
_packetCaptureFlushTimer?.cancel();
_lowBatteryCheckTimer?.cancel();
unawaited(_flushPacketCaptureLogs());
// Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange);
// Clear location service callbacks
locationTrackingService.onPositionUpdate = null;
locationTrackingService.onBroadcastSent = null;
locationTrackingService.onError = null;
locationTrackingService.onTrackingStateChanged = null;
locationTrackingService.onFastLocationUpdate = null;
// Dispose the location tracking service to stop GPS stream and clean up resources
locationTrackingService.dispose();
for (final timer in _voiceMissingRetryTimers.values) {
timer.cancel();
}
for (final timer in _imageMissingRetryTimers.values) {
timer.cancel();
}
super.dispose();
}
}