feat: Enhance device configuration and location tracking features

- Refetch device info after updating settings in device_config_screen.dart.
- Update map_tab.dart to use singleton instance of LocationTrackingService and streamline location tracking callbacks.
- Modify ble_response_handler.dart to handle contact not found errors and improve error callback structure.
- Enhance location_tracking_service.dart with retry logic for GPS position acquisition and initial position setting without broadcasting.
- Update meshcore_ble_service.dart to track last contact for auto-recovery on errors.
- Improve tile_cache_service.dart error messages and streamline tile download logic.
- Add current GPS location insertion feature in direct_message_sheet.dart with permission checks.
- Update pubspec.lock and pubspec.yaml to include integration_test dependency.
- Add screenshot automation script for iOS and Android devices.
- Create integration test driver for screenshot capturing.
This commit is contained in:
Janez T
2025-10-18 14:55:41 +02:00
parent 3a0bcabdea
commit 5fd090b5dc
29 changed files with 2643 additions and 129 deletions

View File

@@ -198,6 +198,23 @@ class AppProvider with ChangeNotifier {
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
};
// 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,
);
};
}
/// Initialize the app (load contacts, sync time, etc.)

View File

@@ -11,6 +11,23 @@ import 'helpers/room_login_manager.dart';
import 'helpers/message_delivery_tracker.dart';
import 'helpers/ping_tracker.dart';
/// Pending send operation for auto-recovery
class _PendingSendOperation {
final Uint8List contactPublicKey;
final String text;
final String? messageId;
final Contact? contact;
final int retryAttempt;
_PendingSendOperation({
required this.contactPublicKey,
required this.text,
this.messageId,
this.contact,
this.retryAttempt = 0,
});
}
/// Result of a ping (telemetry request) operation
class PingResult {
final bool success;
@@ -117,6 +134,9 @@ class ConnectionProvider with ChangeNotifier {
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
ConnectionProvider() {
_initializeBleService();
}
@@ -147,8 +167,9 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners();
};
_bleService.onError = (error) {
_bleService.onError = (error, {int? errorCode}) {
print('⚠️ [Provider] BLE error received: $error');
print(' Error code: ${errorCode ?? "none"}');
print(' Current connection state: ${_deviceInfo.connectionState}');
_error = error;
@@ -169,6 +190,57 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners();
};
_bleService.onContactNotFound = (contactPublicKey) async {
print('🔧 [Provider] Contact not found error detected - initiating auto-recovery');
if (contactPublicKey == null) {
print(' ⚠️ No contact public key available for recovery');
return;
}
// Generate operation ID from public key
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final pendingOp = _pendingSendOperations[operationId];
if (pendingOp == null || pendingOp.contact == null) {
print(' ⚠️ No pending operation found for recovery: $operationId');
return;
}
print(' 📋 Found pending operation for: ${pendingOp.contact!.advName}');
print(' 📤 Step 1: Adding contact to radio...');
try {
// Step 1: Add the contact to the radio
await _bleService.addOrUpdateContact(pendingOp.contact!);
// Small delay to ensure contact is added before retrying
await Future.delayed(const Duration(milliseconds: 300));
print(' ✅ Contact added successfully');
print(' 🔄 Step 2: Retrying message send...');
// Step 2: Retry the send operation
await _bleService.sendTextMessage(
contactPublicKey: pendingOp.contactPublicKey,
text: pendingOp.text,
attempt: pendingOp.retryAttempt,
);
print(' ✅ Auto-recovery completed - message resent');
// Clear pending operation after successful recovery
_pendingSendOperations.remove(operationId);
} catch (e) {
print(' ❌ Auto-recovery failed: $e');
_error = 'Auto-recovery failed: $e';
notifyListeners();
// Clear pending operation after failed recovery
_pendingSendOperations.remove(operationId);
}
};
_bleService.onContactReceived = (contact) {
onContactReceived?.call(contact);
};
@@ -529,6 +601,7 @@ class ConnectionProvider with ChangeNotifier {
_roomLoginManager
.clearRoomLoginStates(); // Clear login states on disconnect
_pingTracker.clearAll(); // Clear pending pings on disconnect
_pendingSendOperations.clear(); // Clear pending operations on disconnect
notifyListeners();
}
@@ -582,11 +655,13 @@ class ConnectionProvider with ChangeNotifier {
///
/// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
String? messageId,
Contact? contact,
int retryAttempt = 0,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
@@ -595,9 +670,13 @@ class ConnectionProvider with ChangeNotifier {
}
try {
// Log path status if contact info is available
// Log path status and retry info
if (contact != null) {
print('📤 [ConnectionProvider] Sending message to ${contact.advName}');
if (retryAttempt > 0) {
print('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)');
} else {
print('📤 [ConnectionProvider] Sending message to ${contact.advName}');
}
print(' Type: ${contact.type.displayName}');
print(' Path status: ${contact.pathDescription}');
if (contact.hasPath) {
@@ -605,6 +684,21 @@ class ConnectionProvider with ChangeNotifier {
} else {
print(' ⚠️ No path available - will use flood mode');
}
} else if (retryAttempt > 0) {
print('🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)');
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) {
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
_pendingSendOperations[operationId] = _PendingSendOperation(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
print(' 📝 Tracked pending operation for auto-recovery: $operationId');
}
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
@@ -615,12 +709,23 @@ class ConnectionProvider with ChangeNotifier {
print(' Added message ID to pending queue BEFORE sending: $messageId');
}
// Send the message
// Send the message with retry attempt info
await _bleService.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
attempt: retryAttempt,
);
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
// Use a small delay to allow error response to arrive before clearing
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
}
return true;
} catch (e) {
_error = 'Failed to send message: $e';

View File

@@ -0,0 +1,89 @@
import '../../models/message.dart';
import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
class MessageRetryManager {
// Track retry state for each message ID
final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {};
// Progressive timeout values in milliseconds
static const List<int> _timeouts = [4000, 8000, 12000];
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
}
return _timeouts[attempt];
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts
/// - Contact still has no path
/// - Hasn't already used flood fallback
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
!contact.hasPath &&
!message.usedFloodFallback;
}
/// Track a retry attempt for a message
void trackRetry(String messageId, int attempt) {
_retryAttempts[messageId] = attempt;
_lastRetryTimes[messageId] = DateTime.now();
}
/// Clear retry tracking for a message (on success or permanent failure)
void clearRetry(String messageId) {
_retryAttempts.remove(messageId);
_lastRetryTimes.remove(messageId);
}
/// Clear all retry tracking (on disconnect)
void clearAll() {
_retryAttempts.clear();
_lastRetryTimes.clear();
}
/// Get current retry attempt for a message (for debugging)
int? getRetryAttempt(String messageId) {
return _retryAttempts[messageId];
}
/// Get last retry time for a message (for debugging)
DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId];
}
}

View File

@@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../services/message_storage_service.dart';
import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
/// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier {
@@ -23,6 +25,21 @@ class MessagesProvider with ChangeNotifier {
// Track timeout timers for pending messages
final Map<int, Timer> _timeoutTimers = {};
// Retry management
final MessageRetryManager _retryManager = MessageRetryManager();
// Track which contact each sent message was sent to (for retry logic)
final Map<String, Contact> _messageContactMap = {};
// Callback to connection provider for sending messages (set by AppProvider)
Future<bool> Function({
required Uint8List contactPublicKey,
required String text,
required String messageId,
required Contact contact,
int retryAttempt,
})? sendMessageCallback;
List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages =>
@@ -176,7 +193,16 @@ class MessagesProvider with ChangeNotifier {
/// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp
/// 4. Same text content
///
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
/// because they can be retried with different message IDs
bool _isDuplicate(Message message) {
// Sent messages (our own messages) should never be considered duplicates
// They can be retried multiple times with different IDs
if (message.isSentMessage) {
return false;
}
return _messages.any((existing) {
// Check message type matches
if (existing.messageType != message.messageType) {
@@ -468,7 +494,7 @@ class MessagesProvider with ChangeNotifier {
}
/// Add a sent message with initial status
void addSentMessage(Message message) {
void addSentMessage(Message message, {Contact? contact}) {
print('📝 [MessagesProvider] addSentMessage called');
print(' Message ID: ${message.id}');
print(' Message type: ${message.messageType}');
@@ -493,6 +519,12 @@ class MessagesProvider with ChangeNotifier {
print(' ✅ Message added to list at index ${_messages.length - 1}');
print(' Total messages in list: ${_messages.length}');
// Store contact mapping for retry logic
if (contact != null) {
_messageContactMap[message.id] = contact;
print(' ✅ Stored contact mapping for retry logic');
}
// If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker();
@@ -599,6 +631,9 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending
_pendingSentMessages.remove(ackCode);
// Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id);
print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)');
print(' Updated status to: ${updatedMessage.deliveryStatus}');
print(' Calling notifyListeners() to update UI');
@@ -640,15 +675,130 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Update message status to failed
/// Update message status to failed (with retry logic)
void markMessageFailed(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
print('⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId');
return;
}
final message = _messages[index];
final contact = _messageContactMap[messageId];
print('❌ [MessagesProvider] Message $messageId timeout/failed');
print(' Retry attempt: ${message.retryAttempt}');
print(' Contact has path: ${contact?.hasPath ?? false}');
print(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail
if (contact != null && _retryManager.canRetry(message, contact)) {
// RETRY: Contact has path and retry attempts < 3
_scheduleRetry(messageId, message, contact);
} else if (contact != null && _retryManager.shouldUseFloodFallback(message, contact)) {
// FLOOD FALLBACK: After 3 retries failed, try flood once
_sendWithFloodMode(messageId, message, contact);
} else {
// PERMANENTLY FAILED: No retry possible
_markAsPermanentlyFailed(messageId, message);
}
}
/// Schedule a retry with progressive timeout
void _scheduleRetry(String messageId, Message message, Contact contact) {
final nextAttempt = message.retryAttempt + 1;
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
print('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId');
print(' Timeout: ${timeout}ms');
// Update message with new retry attempt
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
final message = _messages[index];
final updatedMessage = message.copyWith(
_messages[index] = message.copyWith(
retryAttempt: nextAttempt,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Cancel old timeout timer
if (message.expectedAckTag != null) {
_timeoutTimers[message.expectedAckTag]?.cancel();
_timeoutTimers.remove(message.expectedAckTag);
_pendingSentMessages.remove(message.expectedAckTag);
}
// Track retry
_retryManager.trackRetry(messageId, nextAttempt);
notifyListeners(); // Update UI to show "Retrying (X/3)..."
// Schedule actual retry after delay
Timer(Duration(milliseconds: timeout), () async {
print('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId');
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: nextAttempt,
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry');
}
});
_persistMessages();
}
}
/// Send message with flood mode as last resort
Future<void> _sendWithFloodMode(String messageId, Message message, Contact contact) async {
print('🌊 [MessagesProvider] Trying flood mode for message $messageId');
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
usedFloodFallback: true,
deliveryStatus: MessageDeliveryStatus.sending,
);
// Cancel old timeout timer
if (message.expectedAckTag != null) {
_timeoutTimers[message.expectedAckTag]?.cancel();
_timeoutTimers.remove(message.expectedAckTag);
_pendingSentMessages.remove(message.expectedAckTag);
}
notifyListeners();
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood');
}
_persistMessages();
}
}
/// Mark message as permanently failed
void _markAsPermanentlyFailed(String messageId, Message message) {
print('❌ [MessagesProvider] Message $messageId permanently failed');
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
deliveryStatus: MessageDeliveryStatus.failed,
);
_messages[index] = updatedMessage;
// Cancel timeout timer if it exists
if (message.expectedAckTag != null) {
@@ -657,13 +807,61 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag);
}
print('❌ [MessagesProvider] Message $messageId marked as failed');
// Clear retry tracking
_retryManager.clearRetry(messageId);
_persistMessages();
notifyListeners();
}
}
/// Resend a failed message
Future<void> resendMessage(String messageId) async {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
print('⚠️ [MessagesProvider] resendMessage: Message not found: $messageId');
return;
}
final message = _messages[index];
final contact = _messageContactMap[messageId];
if (contact == null) {
print('⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId');
return;
}
print('🔁 [MessagesProvider] Resending message $messageId');
// Reset retry state
_messages[index] = message.copyWith(
retryAttempt: 0,
usedFloodFallback: false,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Clear retry tracking
_retryManager.clearRetry(messageId);
notifyListeners();
// Send again
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0,
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend');
}
_persistMessages();
}
@override
void dispose() {
// Cancel all pending timeout timers
@@ -671,6 +869,10 @@ class MessagesProvider with ChangeNotifier {
timer.cancel();
}
_timeoutTimers.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
}