Compare commits

...

22 Commits

Author SHA1 Message Date
zjs81 64d75dde45 chore: update version to 7.0.0+8 in pubspec.yaml 2026-03-14 18:46:29 -07:00
zjs81 9199aab7f7 Merge pull request #297 from zjs81/dev-improments
Improvements to path tracing and location handling
2026-03-14 18:42:58 -07:00
zjs81 60e8ee0130 fix: simplify method call for writing data in UsbSerialService 2026-03-14 18:41:57 -07:00
zjs81 6dfb7a4b69 fix: auto-add flag parsing, contact cache restore, and USB reconnect
- Fix operator precedence bug in _handleAutoAddConfig where `flags &
  flag != 0` was parsed as `flags & (flag != 0)`, always checking bit 0
  instead of the correct flag bit
- Populate _contacts from cache in loadContactCache() so contacts
  persist across app restarts
- Toggle DTR low→high on USB connect to force device to see a fresh
  connection
- Add 10ms inter-frame delay for USB sends to prevent missed responses
- Deassert DTR before closing USB port on disconnect/dispose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:41:21 -07:00
zjs81 28a423e0a8 fix: correct location validation and clean up target contact handling
- Fix asymmetric lat/lon validation in _handleContactAdvert (was checking
  longitude != 0 for latitude; now uses (latitude != 0 || longitude != 0)
  for both)
- Remove duplicate targetGuessed assignment in path_trace_map
- Rename public target field to private _targetContact, use local variable
  to avoid unnecessary null-aware operators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:14:39 -07:00
Winston Lowe 3593cfa843 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:44 -07:00
Winston Lowe dc85e7a41c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:17 -07:00
Winston Lowe 9265daaf16 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:09 -07:00
Winston Lowe 4b744184c2 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:09:54 -07:00
zjs81 64698e0be6 Merge pull request #295 from ericszimmermann/ez_group_dropdown3
squashed PR for Dropdown Group Menu
2026-03-14 18:05:22 -07:00
zjs81 3dd9037be3 Merge remote-tracking branch 'origin/main' into ez_group_dropdown3
# Conflicts:
#	lib/main.dart
2026-03-14 18:02:31 -07:00
Winston Lowe 06a906f4f7 Enhance location handling and improve path trace functionality across screens 2026-03-14 17:51:24 -07:00
zjs81 054a84031e Merge pull request #296 from zjs81/feature/ml-timeout-prediction
feat: add ML-based adaptive timeout prediction using LinearRegressor
2026-03-14 17:39:22 -07:00
zjs81 fffcff3b74 fix: cancel persist timer on dispose to prevent post-dispose writes 2026-03-14 17:39:01 -07:00
zjs81 b336aedbc5 fix: address PR #296 code review feedback
- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
2026-03-14 17:32:08 -07:00
zjs81 2ee2358ecc feat: add ML-based adaptive timeout prediction using LinearRegressor
Train a linear regression model on actual message delivery times to
predict tighter timeouts, replacing worst-case physics estimates.
Features: path length, message bytes, seconds since last RX, flood mode.
Global model with per-contact blending after 10+ observations per contact.
Falls back to existing physics formula when model has insufficient data.
2026-03-14 16:56:11 -07:00
Winston Lowe 24fa78741b add TCP server address and port settings to AppSettings and update TcpScreen 2026-03-14 11:46:05 -07:00
Winston Lowe 79a45c527b Unify contact retrieval by introducing allContacts getter 2026-03-14 11:45:47 -07:00
zjs81 8b280b37be Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
2026-03-14 09:55:02 -07:00
zjs81 fa4da979af feat: enhance location update feedback and improve message retry error handling 2026-03-14 09:54:50 -07:00
zjs81 91608ff09e feat: improve message matching logic and update notification IDs for advertisements 2026-03-14 09:44:37 -07:00
zjs81 71f59d23df feat: add set-as-my-location from map long-press, connector and UI improvements
Add "Set as my location" option to the map long-press bottom sheet,
allowing users to set their device position directly from the map.
Includes connector, chat, contacts, and message retry service improvements.
2026-03-14 09:33:37 -07:00
61 changed files with 1436 additions and 289 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+180 -57
View File
@@ -19,6 +19,7 @@ import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart';
@@ -166,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingContacts = false;
bool _isLoadingChannels = false;
bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
bool _batteryRequested = false;
bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false;
@@ -199,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Serializes path operations (setContactPath/clearContactPath) to prevent
// interleaved async calls from leaving in-memory state inconsistent with device.
Future<void> _pathOpLock = Future.value();
Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern)
@@ -286,6 +294,10 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
List<Contact> get allContacts => List.unmodifiable([
..._contacts,
..._discoveredContacts.where((c) => !c.isActive),
]);
List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts);
}
@@ -558,6 +570,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners();
}
}
@@ -576,6 +592,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners();
}
}
@@ -657,6 +677,7 @@ class MeshCoreConnector extends ChangeNotifier {
BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
}) {
_retryService = retryService;
_pathHistoryService = pathHistoryService;
@@ -664,6 +685,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
_usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService);
@@ -678,13 +700,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) =>
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
calculateTimeoutCallback:
(pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService,
debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult,
onDeliveryObservedCallback:
(contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
_timeoutPredictionService?.recordObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
tripTimeMs: tripTimeMs,
secondsSinceLastRx: secSinceRx,
);
},
);
}
@@ -693,6 +730,9 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys
..clear()
..addAll(cached.map((c) => c.publicKeyHex));
_contacts
..clear()
..addAll(cached);
for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
}
@@ -1525,6 +1565,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
@@ -1740,18 +1784,33 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath,
int pathLen,
) async {
if (!isConnected) return;
// Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame(
buildUpdateContactPathFrame(
contact.publicKey,
customPath,
pathLen,
type: contact.type,
flags: contact.flags,
name: contact.name,
),
);
await sendFrame(
buildUpdateContactPathFrame(
contact.publicKey,
customPath,
pathLen,
type: contact.type,
flags: contact.flags,
name: contact.name,
),
);
// USB writes return instantly (no BLE flow control), so give the firmware
// time to persist the path change before subsequent commands.
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
completer.complete();
}
}
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
@@ -2136,25 +2195,34 @@ class MeshCoreConnector extends ChangeNotifier {
}
Future<void> clearContactPath(Contact contact) async {
if (!isConnected) return;
// Serialize path operations to prevent interleaved async calls.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey));
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith(
pathOverride: null,
pathOverrideBytes: null,
pathLength: -1,
path: Uint8List(0),
await sendFrame(buildResetPathFrame(contact.publicKey));
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
);
notifyListeners();
unawaited(_persistContacts());
if (existingIndex >= 0) {
final existing = _contacts[existingIndex];
// Preserve pathOverride and pathOverrideBytes — only reset device path
_contacts[existingIndex] = existing.copyWith(
pathLength: -1,
path: Uint8List(0),
);
notifyListeners();
unawaited(_persistContacts());
}
} finally {
completer.complete();
}
// The device will send updated contact info with path_len = -1
}
void updateContactInMemory(
@@ -2463,6 +2531,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) {
if (data.isEmpty) return;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame);
@@ -2490,6 +2559,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true;
notifyListeners();
break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert:
debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler
@@ -2836,41 +2908,73 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Calculate timeout for a message based on radio settings and path length
/// Returns timeout in milliseconds, considering number of hops
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
// If we have radio settings, use them for accurate calculation
/// Estimate single-packet airtime in ms from radio settings, or a fallback.
int _estimateAirtimeMs(int messageBytes) {
if (_currentFreqHz != null &&
_currentBwHz != null &&
_currentSf != null &&
_currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout(
freqHz: _currentFreqHz!,
bwHz: _currentBwHz!,
sf: _currentSf!,
cr: cr,
pathLength: pathLength,
messageBytes: messageBytes,
return calculateLoRaAirtime(
payloadBytes: messageBytes,
spreadingFactor: _currentSf!,
bandwidthHz: _currentBwHz!,
codingRate: cr,
lowDataRateOptimize: _currentSf! >= 11,
);
}
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
// Fallback: Conservative estimates based on typical settings
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
const estimatedAirtime = 50;
/// Physics-based worst-case timeout (ceiling).
int _physicsMaxTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime
return 500 + (16 * estimatedAirtime);
return 500 + (16 * airtime);
} else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
}
}
/// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({
required int pathLength,
int messageBytes = 100,
String? contactKey,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx,
);
if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
}
return physicsMax;
}
void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame);
if (contact != null) {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
@@ -4679,6 +4783,12 @@ class MeshCoreConnector extends ChangeNotifier {
(_autoAddRoomServers && type == advTypeRoom) ||
(_autoAddSensors && type == advTypeSensor)) {
_handleContactAdvert(newContact);
_handleDiscovery(
newContact,
rawPacket,
noNotify: true,
addActive: true,
);
} else {
_handleDiscovery(newContact, rawPacket);
}
@@ -4703,8 +4813,20 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude,
longitude: hasLocation ? longitude : existing.longitude,
latitude:
hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length,
@@ -4775,11 +4897,11 @@ class MeshCoreConnector extends ChangeNotifier {
try {
reader.skipBytes(1); // Skip the response code byte
final flags = reader.readByte();
_autoAddUsers = flags & autoAddChatFlag != 0;
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0;
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0;
_autoAddSensors = flags & autoAddSensorFlag != 0;
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0;
_autoAddUsers = (flags & autoAddChatFlag) != 0;
_autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
_autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
} catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
}
@@ -4789,6 +4911,7 @@ class MeshCoreConnector extends ChangeNotifier {
Contact contact,
Uint8List rawPacket, {
bool noNotify = false,
bool addActive = false,
}) {
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
@@ -4809,7 +4932,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude,
lastSeen: contact.lastSeen,
flags: 0,
isActive: false,
isActive: addActive,
);
notifyListeners();
unawaited(_persistDiscoveredContacts());
@@ -4827,7 +4950,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude,
lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: false,
isActive: addActive,
flags: 0,
);
_discoveredContacts.add(disContact);
@@ -64,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management ---
void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName);
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване"
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение"
}
+2 -1
View File
@@ -1916,5 +1916,6 @@
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen"
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
}
+1
View File
@@ -808,6 +808,7 @@
"map_source": "Source",
"map_flags": "Flags",
"map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label",
"map_label": "Label",
"map_pointOfInterest": "Point of interest",
+2 -1
View File
@@ -1916,5 +1916,6 @@
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento"
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte"
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery"
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione"
}
+6
View File
@@ -2752,6 +2752,12 @@ abstract class AppLocalizations {
/// **'Share marker here'**
String get map_shareMarkerHere;
/// No description provided for @map_setAsMyLocation.
///
/// In en, this message translates to:
/// **'Set as my location'**
String get map_setAsMyLocation;
/// No description provided for @map_pinLabel.
///
/// In en, this message translates to:
+3
View File
@@ -1514,6 +1514,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override
String get map_pinLabel => 'Етикетиране на пин';
+3
View File
@@ -1516,6 +1516,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override
String get map_pinLabel => 'Pin Name';
+3
View File
@@ -1490,6 +1490,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override
String get map_pinLabel => 'Pin label';
+3
View File
@@ -1513,6 +1513,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override
String get map_pinLabel => 'Etiqueta de marcador';
+3
View File
@@ -1521,6 +1521,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override
String get map_pinLabel => 'Étiquete de repin';
+3
View File
@@ -1513,6 +1513,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override
String get map_pinLabel => 'Etichetta PIN';
+3
View File
@@ -1505,6 +1505,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override
String get map_pinLabel => 'Label vastzetten';
+3
View File
@@ -1515,6 +1515,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override
String get map_pinLabel => 'Oznacz etykietę';
+3
View File
@@ -1514,6 +1514,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override
String get map_pinLabel => 'Rótulo de marcador';
+3
View File
@@ -1516,6 +1516,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override
String get map_pinLabel => 'Метка';
+3
View File
@@ -1507,6 +1507,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override
String get map_pinLabel => 'Označka upozornenia';
+3
View File
@@ -1501,6 +1501,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override
String get map_pinLabel => 'Oznaka za pritrditev';
+3
View File
@@ -1497,6 +1497,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override
String get map_pinLabel => 'Fästetikett';
+3
View File
@@ -1513,6 +1513,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override
String get map_pinLabel => 'Мітка піна';
+3
View File
@@ -1424,6 +1424,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override
String get map_pinLabel => '标签';
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven"
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania"
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta"
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização"
}
+2 -1
View File
@@ -1128,5 +1128,6 @@
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery"
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov"
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov"
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter"
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats"
}
+2 -1
View File
@@ -1888,5 +1888,6 @@
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття"
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження"
}
+2 -1
View File
@@ -1893,5 +1893,6 @@
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人"
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置"
}
+8
View File
@@ -20,6 +20,7 @@ import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@@ -41,6 +42,7 @@ void main() async {
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings
await appSettingsService.loadSettings();
@@ -59,6 +61,7 @@ void main() async {
await chatTextScaleService.initialize();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
// Wire up connector with services
connector.initialize(
@@ -68,6 +71,7 @@ void main() async {
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
);
await connector.loadContactCache();
@@ -90,6 +94,7 @@ void main() async {
mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
),
);
}
@@ -126,6 +131,7 @@ class MeshCoreApp extends StatelessWidget {
final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService;
final UiViewStateService uiViewStateService;
final TimeoutPredictionService timeoutPredictionService;
const MeshCoreApp({
super.key,
@@ -139,6 +145,7 @@ class MeshCoreApp extends StatelessWidget {
required this.mapTileCacheService,
required this.chatTextScaleService,
required this.uiViewStateService,
required this.timeoutPredictionService,
});
@override
@@ -155,6 +162,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
+12
View File
@@ -40,6 +40,8 @@ class AppSettings {
final UnitSystem unitSystem;
final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
final String tcpServerAddress;
final int tcpServerPort;
AppSettings({
this.clearPathOnMaxRetry = false,
@@ -68,6 +70,8 @@ class AppSettings {
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
@@ -100,6 +104,8 @@ class AppSettings {
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
};
}
@@ -157,6 +163,8 @@ class AppSettings {
{},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
);
}
@@ -187,6 +195,8 @@ class AppSettings {
UnitSystem? unitSystem,
Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
String? tcpServerAddress,
int? tcpServerPort,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -225,6 +235,8 @@ class AppSettings {
mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
);
}
}
+14 -39
View File
@@ -65,7 +65,17 @@ class Contact {
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({
@@ -108,7 +118,7 @@ class Contact {
}
String get pathIdList {
final pathBytes = _pathBytesForDisplay;
final pathBytes = pathBytesForDisplay;
if (pathBytes.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
@@ -130,43 +140,7 @@ class Contact {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
Uint8List? get traceRouteBytes {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
Uint8List get pathBytesForDisplay {
if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0);
@@ -197,6 +171,7 @@ class Contact {
double? lat, lon;
final latRaw = reader.readInt32LE();
final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;
+43
View File
@@ -0,0 +1,43 @@
class DeliveryObservation {
final String contactKey;
final int pathLength;
final int messageBytes;
final int secondsSinceLastRx;
final bool isFlood;
final int deliveryMs;
final DateTime timestamp;
DeliveryObservation({
required this.contactKey,
required this.pathLength,
required this.messageBytes,
required this.secondsSinceLastRx,
required this.isFlood,
required this.deliveryMs,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'contact_key': contactKey,
'path_length': pathLength,
'message_bytes': messageBytes,
'seconds_since_last_rx': secondsSinceLastRx,
'is_flood': isFlood,
'delivery_ms': deliveryMs,
'timestamp': timestamp.toIso8601String(),
};
}
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
return DeliveryObservation(
contactKey: json['contact_key'] as String,
pathLength: json['path_length'] as int,
messageBytes: json['message_bytes'] as int,
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
isFlood: json['is_flood'] as bool,
deliveryMs: json['delivery_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
+5 -10
View File
@@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
@@ -65,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath,
flipPathRound: true,
reversePathRound: !message.isOutgoing && !channelMessage,
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
),
),
),
@@ -367,10 +365,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[];
+76 -32
View File
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
// Show path details if we have non-empty path data (from device or override)
final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else {
await connector.setPathOverride(contact, pathLen: null);
}
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
color: activeMode == 'auto'
? Theme.of(context).primaryColor
: null,
),
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text(
context.l10n.chat_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
fontWeight: activeMode == 'auto'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'direct',
child: Row(
children: [
Icon(
Icons.near_me,
size: 20,
color: activeMode == 'direct'
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_direct,
style: TextStyle(
fontWeight: activeMode == 'direct'
? FontWeight.bold
: FontWeight.normal,
),
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon(
Icons.waves,
size: 20,
color: isFloodMode
color: activeMode == 'flood'
? Theme.of(context).primaryColor
: null,
),
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text(
context.l10n.chat_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
fontWeight: activeMode == 'flood'
? FontWeight.bold
: FontWeight.normal,
),
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
),
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageTo(widget.contact.name),
context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom();
});
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
final messageIndex = index;
Contact contact = widget.contact;
Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final resolvedContact = _resolveContact(connector);
return _MessageBubble(
message: message,
senderName: widget.contact.type == advTypeRoom
senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
connector.sendMessage(widget.contact, text);
connector.sendMessage(_resolveContact(connector), text);
_textController.clear();
_textFieldFocusNode.requestFocus();
}
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: pathLength,
pathBytes: pathBytes,
);
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context);
await _notifyPathSet(
connector,
widget.contact,
_resolveContact(connector),
pathBytes,
path.hopCount,
);
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
onTap: () async {
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: -1,
);
if (!context.mounted) return;
@@ -817,7 +858,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
),
),
@@ -986,7 +1027,7 @@ class _ChatScreenState extends State<ChatScreen> {
final currentPathLabel = _currentPathLabel(currentContact);
// Filter out the current contact from available contacts
final availableContacts = connector.contacts
final availableContacts = connector.allContacts
.where((c) => c != widget.contact)
.toList();
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
);
if (result == null) {
appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
return; // Cancelled keep existing path
}
if (!mounted) {
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen',
);
await connector.setPathOverride(
widget.contact,
_resolveContact(connector),
pathLen: result.length,
pathBytes: result,
);
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length);
await _notifyPathSet(
connector,
_resolveContact(connector),
result,
result.length,
);
}
void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName;
if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) {
} else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
} else {
senderName = widget.contact.name;
senderName = _resolveContact(connector).name;
}
final pathMessage = ChannelMessage(
senderKey: null,
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message);
},
),
if (widget.contact.type == advTypeRoom)
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text);
connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom
final liveContact = _resolveContact(connector);
final senderName = liveContact.type == advTypeRoom
? senderContact.name
: null;
final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText);
connector.sendMessage(_resolveContact(connector), reactionText);
}
}
+19 -6
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/services/notification_service.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart';
@@ -66,6 +67,13 @@ class _ContactsScreenState extends State<ContactsScreen>
.contactsSearchText;
_loadGroups();
_setupFrameListener();
_clearAdvertNotifications();
}
void _clearAdvertNotifications() {
final connector = context.read<MeshCoreConnector>();
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
NotificationService().clearAdvertNotifications(contactIds);
}
@override
@@ -1231,7 +1239,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
@@ -1239,10 +1247,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
),
),
);
@@ -1267,10 +1277,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
),
),
);
@@ -1312,7 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
),
),
+20 -13
View File
@@ -137,10 +137,7 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings;
final allContacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts.where((c) => !c.isActive),
];
final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
? allContacts
@@ -179,20 +176,13 @@ class _MapScreenState extends State<MapScreen> {
// Filter by location
final contactsWithLocation = filteredByKeyPrefix.where((c) {
if (!c.hasLocation) {
return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
return c.hasLocation;
}).toList();
// All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = allContacts
.where(
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.where((c) => c.hasLocation)
.toList();
// Compute guessed locations with caching
@@ -1509,6 +1499,23 @@ class _MapScreenState extends State<MapScreen> {
);
},
),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
+1 -4
View File
@@ -124,10 +124,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
try {
final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
+78 -33
View File
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
final String title;
final Uint8List path;
final int? repeaterId;
final bool flipPathRound;
final bool reversePathRound;
final bool flipPathAround;
final bool reversePathAround;
final Contact? targetContact;
const PathTraceMapScreen({
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
required this.title,
required this.path,
this.repeaterId,
this.flipPathRound = false,
this.reversePathRound = false,
this.flipPathAround = false,
this.reversePathAround = false,
this.targetContact,
});
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
@@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
});
}
final Uint8List path;
Uint8List pathTmp = widget.reversePathRound
final pathTmp = widget.reversePathAround
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
if (widget.flipPathRound) {
path = buildPath(pathTmp);
} else {
path = pathTmp;
}
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -263,10 +259,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> pathContacts = {};
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
@@ -312,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact.
LatLng? targetPos;
bool targetGuessed = false;
final target = widget.targetContact;
if (target != null) {
if (target.hasLocation) {
targetPos = LatLng(target.latitude!, target.longitude!);
} else if (pathData.isNotEmpty) {
_targetContact = widget.targetContact;
if (_targetContact != null) {
final tc = _targetContact!;
if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits
// in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1)
? pathData[(pathData.length - 1) ~/ 2]
: pathData.last;
final peers = connector.contacts
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = widget.reversePathAround
? widget.path.first
: widget.path.last;
final peers = connector.allContacts
.where(
(c) =>
c.hasLocation &&
@@ -339,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length;
const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
}
}
}
@@ -353,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
@@ -361,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred);
}
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
}
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1
? [
Polyline(
@@ -451,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
],
),
),
if (_hasData) _buildMapPathTrace(context, tileCache),
if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
@@ -480,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers(
List<int> pathData, {
required bool showLabels,
required Contact? target,
}) {
final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue;
if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps
? LatLng(contact.latitude!, contact.longitude!)
: inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add(
Marker(
point: point,
@@ -532,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
);
}
hopLastLast = hopLast;
hopLast = hop;
}
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@@ -581,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker.
final targetPos = _targetContactPosition;
if (targetPos != null) {
if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?';
final targetName = target.name;
markers.add(
Marker(
point: targetPos,
@@ -719,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace(
BuildContext context,
MapTileCacheService tileCache,
Contact? target,
) {
return FlutterMap(
key: _mapKey,
@@ -757,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers(
_traceData!.pathData,
showLabels: _showNodeLabels,
target: target,
),
),
],
+15 -2
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
@@ -27,8 +28,14 @@ class _TcpScreenState extends State<TcpScreen> {
@override
void initState() {
super.initState();
_hostController = TextEditingController();
_portController = TextEditingController(text: '5000');
_hostController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
@@ -39,6 +46,12 @@ class _TcpScreenState extends State<TcpScreen> {
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected &&
!_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
+10 -7
View File
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) {
if (!_enabled && !kDebugMode) return;
if (!_enabled) {
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries);
}
notifyListeners();
if (!noNotify) {
notifyListeners();
}
// Also print to console for development
debugPrint('[$tag] $message');
}
void info(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.info);
void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
}
void warn(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.warning);
void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
}
void error(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.error);
void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
}
void clear() {
+8
View File
@@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
}
+178 -54
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback;
Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService();
@@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
@@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
_appSettingsService = appSettingsService;
_debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
}
/// Compute expected ACK hash using same algorithm as firmware:
@@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message);
}
await _attemptSend(messageId);
// Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
if (!_activeMessages.any(
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
)) {
_sendNextForContact(contactKey);
}
}
void _sendNextForContact(String contactKey) {
final queue = _sendQueue[contactKey];
if (queue == null) return;
// Drain stale entries iteratively instead of recursing.
while (queue.isNotEmpty) {
final messageId = queue.removeAt(0);
if (_pendingMessages.containsKey(messageId)) {
_activeMessages.add(messageId);
_attemptSend(messageId).catchError((e) {
debugPrint('_attemptSend threw for $messageId: $e');
final msg = _pendingMessages[messageId];
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
_sendNextForContact(contactKey);
}
Future<void> _attemptSend(String messageId) async {
@@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}',
);
_clearContactPathCallback!(contact);
await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes
@@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
}
}
// Re-validate after async gap a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages[messageId];
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
debugPrint(
'_attemptSend: message $messageId resolved during path sync, aborting',
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
);
return;
}
final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
}
}
@@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
}
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) {
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
);
for (var entry in _pendingMessageQueuePerContact.entries) {
// Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key;
final queue = entry.value;
if (queue.isNotEmpty) {
// Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
@@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
);
break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
}
debugPrint('Dequeued stale message $candidateMessageId - skipping');
}
if (messageId != null) break;
}
}
@@ -357,25 +433,33 @@ class MessageRetryService extends ChangeNotifier {
);
}
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
// Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) {
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(
if (_calculateTimeoutCallback != null) {
final calculated = _calculateTimeoutCallback!(
pathLengthValue,
message.text.length,
contactKey: contact.publicKeyHex,
);
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
// calculateTimeout tries ML first, falls back to physics.
// Use calculated value if device didn't provide one, or if ML
// produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
}
final updatedMessage = message.copyWith(
@@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
} else {
// Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed);
// Move ACK hashes to history before removing
_moveAckHashesToHistory(messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
_pendingMessages[messageId] = failedMessage;
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
}
notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
}
}
@@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
}
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!;
final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs,
);
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
// Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) {
@@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
true,
tripTimeMs,
);
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
}
notifyListeners();
@@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear();
_ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose();
}
}
+58 -1
View File
@@ -232,7 +232,9 @@ class NotificationService {
try {
await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName,
notificationDetails: notificationDetails,
@@ -331,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id);
}
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
//
// Public notification methods (rate limiting is enforced automatically)
//
+31
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import '../models/delivery_observation.dart';
import '../models/path_history.dart';
import '../storage/prefs_manager.dart';
@@ -6,6 +7,7 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<void> savePathHistory(
String contactPubKeyHex,
@@ -122,4 +124,33 @@ class StorageService {
final prefs = PrefsManager.instance;
await prefs.remove(_repeaterPasswordsKey);
}
Future<void> saveDeliveryObservations(
List<DeliveryObservation> observations,
) async {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
await prefs.setString(_deliveryObservationsKey, jsonStr);
}
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_deliveryObservationsKey);
if (jsonStr == null) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> clearDeliveryObservations() async {
final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey);
}
}
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}
@@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1();
serial.setFlowControlNone();
serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true);
_serial = serial;
// Update the normalized port name to whichever candidate succeeded.
@@ -249,6 +253,21 @@ class UsbSerialService {
_status = UsbSerialStatus.connected;
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
@@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort();
}
} catch (_) {
@@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread
}
} catch (_) {}
+11
View File
@@ -127,6 +127,17 @@ class UsbSerialService {
}
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
+8 -7
View File
@@ -23,23 +23,23 @@ class AppLogger {
bool get isEnabled => _enabled;
/// Log an info message
void info(String message, {String tag = 'App'}) {
void info(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.info(message, tag: tag);
_service!.info(message, tag: tag, noNotify: noNotify);
}
}
/// Log a warning message
void warn(String message, {String tag = 'App'}) {
void warn(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.warn(message, tag: tag);
_service!.warn(message, tag: tag, noNotify: noNotify);
}
}
/// Log an error message
void error(String message, {String tag = 'App'}) {
void error(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.error(message, tag: tag);
_service!.error(message, tag: tag, noNotify: noNotify);
}
}
@@ -48,9 +48,10 @@ class AppLogger {
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) {
if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level);
_service!.log(message, tag: tag, level: level, noNotify: noNotify);
}
}
}
+2 -2
View File
@@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
),
),
@@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
}
final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts
final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3)
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList();
}
+1 -4
View File
@@ -157,10 +157,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr,
widget.connector.currentSf,
);
final allContacts = [
...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final allContacts = widget.connector.allContacts;
final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name)
@@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
import flutter_local_notifications
import mobile_scanner
import package_info_plus
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
+3 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 6.0.0+7
version: 7.0.0+8
environment:
sdk: ^3.9.2
@@ -69,6 +69,8 @@ dependencies:
material_symbols_icons: ^4.2906.0
web: ^1.1.1
flutter_svg: ^2.0.10+1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0
dev_dependencies:
flutter_test:
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
void main() {
test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100
final data = DataFrame(
[
[1.0, 102.0],
[2.0, 104.0],
[3.0, 106.0],
[4.0, 108.0],
[5.0, 110.0],
[10.0, 120.0],
[20.0, 140.0],
[50.0, 200.0],
[0.0, 100.0],
[100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}');
final model = LinearRegressor(data, 'y');
final testDf = DataFrame(
[
[25.0],
],
headerExists: false,
header: ['x'],
);
final prediction = model.predict(testDf);
final value = prediction.rows.first.first;
debugPrint('Predict x=25 → y=$value (expected ~150)');
expect((value as num).toDouble(), closeTo(150, 5));
});
test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 14.0, 0.0, 2200.0],
[2.0, 50.0, 14.0, 0.0, 5000.0],
[4.0, 50.0, 14.0, 0.0, 9500.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame(
[
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
});
test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs
final data = DataFrame(
[
[0.0, 50.0, 1900.0],
[0.0, 80.0, 2200.0],
[2.0, 50.0, 5000.0],
[2.0, 80.0, 5500.0],
[4.0, 50.0, 9500.0],
[4.0, 80.0, 10000.0],
[0.0, 30.0, 1800.0],
[2.0, 30.0, 4800.0],
[4.0, 30.0, 9000.0],
[0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame(
[
[hops, 50.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint('2-feature: hops=$hops${(pred as num).round()}ms');
}
});
test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 50.0, 16.0, 0.0, 5000.0],
[2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 50.0, 8.0, 0.0, 9500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final tc in [
[0.0, 50.0, 14.0, 0.0],
[2.0, 50.0, 14.0, 0.0],
[4.0, 50.0, 14.0, 0.0],
[-1.0, 50.0, 14.0, 1.0],
]) {
final testDf = DataFrame(
[tc],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
}
});
}
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/delivery_observation.dart';
import 'package:meshcore_open/services/timeout_prediction_service.dart';
void main() {
late TimeoutPredictionService service;
setUp(() {
service = TimeoutPredictionService.noStorage();
});
test('trains on sample data and predicts sensible timeouts', () {
// Simulate realistic delivery data:
// Direct 0-hop messages: ~1500-2500ms
// 2-hop messages: ~4000-6000ms
// 4-hop messages: ~8000-12000ms
// Flood messages: ~3000-8000ms
final sampleData = [
// 0-hop direct
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
// 2-hop direct
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
// 4-hop direct
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
// Flood
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
];
// Feed all observations
for (final obs in sampleData) {
service.recordObservation(
contactKey: obs.contactKey,
pathLength: obs.pathLength,
messageBytes: obs.messageBytes,
tripTimeMs: obs.deliveryMs,
);
}
expect(service.hasModel, isTrue);
expect(service.observationCount, equals(12));
// Predict for different scenarios
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
// All should return non-null (model is trained)
expect(direct0, isNotNull);
expect(direct2, isNotNull);
expect(direct4, isNotNull);
expect(flood, isNotNull);
// More hops should predict longer timeouts
expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!));
// All should be positive
expect(direct0, greaterThan(0));
expect(direct4, greaterThan(0));
// Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):');
debugPrint(' 0-hop direct: ${direct0}ms');
debugPrint(' 2-hop direct: ${direct2}ms');
debugPrint(' 4-hop direct: ${direct4}ms');
debugPrint(' flood: ${flood}ms');
});
test('returns null before minimum observations', () {
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000,
);
}
expect(service.hasModel, isFalse);
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
});
test('caps observations at maxObservations', () {
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000 + i,
);
}
expect(
service.observationCount,
equals(TimeoutPredictionService.maxObservations),
);
});
test('blends per-contact stats after enough observations', () {
// Train with mixed contacts and varied features:
// contactA is fast (0-hop), contactB is slow (2-hop)
for (var i = 0; i < 12; i++) {
service.recordObservation(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 30 + i,
tripTimeMs: 1500,
);
service.recordObservation(
contactKey: 'contactB',
pathLength: 2,
messageBytes: 30 + i,
tripTimeMs: 8000,
);
}
final predA = service.predictTimeout(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 50,
);
final predB = service.predictTimeout(
contactKey: 'contactB',
pathLength: 0,
messageBytes: 50,
);
expect(predA, isNotNull);
expect(predB, isNotNull);
// Contact B (slow) should have a higher predicted timeout than A (fast)
expect(predB!, greaterThan(predA!));
debugPrint('Per-contact blending:');
debugPrint(' contactA (fast): ${predA}ms');
debugPrint(' contactB (slow): ${predB}ms');
});
}
DeliveryObservation _obs({
required int pathLength,
required int messageBytes,
required int deliveryMs,
String contactKey = 'test_contact',
}) {
return DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: 5,
isFlood: pathLength < 0,
deliveryMs: deliveryMs,
timestamp: DateTime.now(),
);
}