diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..31b44b0 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2.4 \ No newline at end of file diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d974b7b..33e5c48 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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 _pathOpLock = Future.value(); Map? _currentCustomVars; // Channel syncing state (sequential pattern) @@ -558,6 +566,10 @@ class MeshCoreConnector extends ChangeNotifier { _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), ); + _notificationService.clearContactNotification( + contactKeyHex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -576,6 +588,10 @@ class MeshCoreConnector extends ChangeNotifier { _channels.isNotEmpty ? _channels : _cachedChannels, ), ); + _notificationService.clearChannelNotification( + channelIndex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -657,6 +673,7 @@ class MeshCoreConnector extends ChangeNotifier { BleDebugLogService? bleDebugLogService, AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, + TimeoutPredictionService? timeoutPredictionService, }) { _retryService = retryService; _pathHistoryService = pathHistoryService; @@ -664,6 +681,7 @@ class MeshCoreConnector extends ChangeNotifier { _bleDebugLogService = bleDebugLogService; _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; + _timeoutPredictionService = timeoutPredictionService; _usbManager.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService); @@ -678,13 +696,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, + ); + }, ); } @@ -1740,18 +1773,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(); + _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.delayed(const Duration(milliseconds: 100)); + } + } finally { + completer.complete(); + } } Future setContactFavorite(Contact contact, bool isFavorite) async { @@ -2136,25 +2184,34 @@ class MeshCoreConnector extends ChangeNotifier { } Future clearContactPath(Contact contact) async { - if (!isConnected) return; + // Serialize path operations to prevent interleaved async calls. + final prev = _pathOpLock; + final completer = Completer(); + _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.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 +2520,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleFrame(List data) { if (data.isEmpty) return; + _lastRxTime = DateTime.now(); final frame = Uint8List.fromList(data); _receivedFramesController.add(frame); @@ -2490,6 +2548,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,38 +2897,68 @@ 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) { diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index ba97771..ca27f8c 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1888,5 +1888,6 @@ "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", - "map_showDiscoveryContacts": "Покажи контакти за откриване" + "map_showDiscoveryContacts": "Покажи контакти за откриване", + "map_setAsMyLocation": "Задайте като моя местоположение" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3b623ba..bd4aed5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 96060a5..5c95e60 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 7cf7898..085b0c8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bbd488c..b7617bb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 06dbd12..728eaac 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b278e36..b38c08f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 8b6c121..96b67d8 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1514,6 +1514,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_shareMarkerHere => 'Споделете маркер тук'; + @override + String get map_setAsMyLocation => 'Задайте като моя местоположение'; + @override String get map_pinLabel => 'Етикетиране на пин'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1aa2109..dcbcd3f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 255f12b..01127c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 36efdb3..fac431e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ee76be3..6932437 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 6566d6a..68c2af3 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 99ca553..4031ddf 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 353f448..6378e74 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -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ę'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8427a49..908ad96 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 74036a2..67011fb 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1516,6 +1516,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_shareMarkerHere => 'Поделиться меткой здесь'; + @override + String get map_setAsMyLocation => 'Установить мое местоположение'; + @override String get map_pinLabel => 'Метка'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index de01520..4f033f9 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index cfe7427..e7c48f6 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -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'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 93b8917..6ccea2f 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -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'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 0db473c..788c9d1 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1513,6 +1513,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_shareMarkerHere => 'Поділитися маркером тут'; + @override + String get map_setAsMyLocation => 'Встановити моє місцезнаходження'; + @override String get map_pinLabel => 'Мітка піна'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 55a4063..be7eeb0 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1424,6 +1424,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_shareMarkerHere => '在此分享标记'; + @override + String get map_setAsMyLocation => '设置为我的位置'; + @override String get map_pinLabel => '标签'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 427b998..648d711 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -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" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index ab980ff..f4f3ac7 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -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ę" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e53649a..dd1698c 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 00b71d0..ea75aca 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1128,5 +1128,6 @@ "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", - "map_showDiscoveryContacts": "Показать контакты Discovery" + "map_showDiscoveryContacts": "Показать контакты Discovery", + "map_setAsMyLocation": "Установить мое местоположение" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index c05a171..636556e 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -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" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index e521ed2..dfc5a69 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -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" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index c2a538d..6a8d801 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -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" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 3d265dc..a50bd78 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1888,5 +1888,6 @@ "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", - "map_showDiscoveryContacts": "Показати контакти Відкриття" + "map_showDiscoveryContacts": "Показати контакти Відкриття", + "map_setAsMyLocation": "Встановити моє місцезнаходження" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 26fc6e6..54d1e3c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1893,5 +1893,6 @@ "tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorTimedOut": "TCP 连接超时。", "tcpConnectionFailed": "TCP 连接失败:{error}", - "map_showDiscoveryContacts": "显示发现联系人" + "map_showDiscoveryContacts": "显示发现联系人", + "map_setAsMyLocation": "设置为我的位置" } diff --git a/lib/main.dart b/lib/main.dart index 1ad1989..c1bf022 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( builder: (context, settingsService, child) { diff --git a/lib/models/delivery_observation.dart b/lib/models/delivery_observation.dart new file mode 100644 index 0000000..a598d2a --- /dev/null +++ b/lib/models/delivery_observation.dart @@ -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 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 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), + ); + } +} diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 0075040..96203ea 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -106,10 +106,9 @@ class _ChatScreenState extends State { 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 { final contact = _resolveContact(connector); final isFloodMode = contact.pathOverride == -1; + final isDirectMode = contact.pathOverride == 0; + final activeMode = isFloodMode + ? 'flood' + : isDirectMode + ? 'direct' + : 'auto'; + return PopupMenuButton( 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 { Icon( Icons.auto_mode, size: 20, - color: !isFloodMode + color: activeMode == 'auto' ? Theme.of(context).primaryColor : null, ), @@ -169,7 +181,30 @@ class _ChatScreenState extends State { 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 { Icon( Icons.waves, size: 20, - color: isFloodMode + color: activeMode == 'flood' ? Theme.of(context).primaryColor : null, ), @@ -192,7 +227,7 @@ class _ChatScreenState extends State { Text( context.l10n.chat_forceFloodMode, style: TextStyle( - fontWeight: isFloodMode + fontWeight: activeMode == 'flood' ? FontWeight.bold : FontWeight.normal, ), @@ -251,7 +286,9 @@ class _ChatScreenState extends State { ), const SizedBox(height: 8), Text( - context.l10n.chat_sendMessageTo(widget.contact.name), + context.l10n.chat_sendMessageTo( + _resolveContact(context.read()).name, + ), style: TextStyle(fontSize: 14, color: Colors.grey[500]), ), ], @@ -269,6 +306,7 @@ class _ChatScreenState extends State { // 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 { ); } 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 { final textScale = context.select( (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 { return; } - connector.sendMessage(widget.contact, text); + connector.sendMessage(_resolveContact(connector), text); _textController.clear(); _textFieldFocusNode.requestFocus(); } @@ -654,7 +693,7 @@ class _ChatScreenState extends State { // 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 { Navigator.pop(context); await _notifyPathSet( connector, - widget.contact, + _resolveContact(connector), pathBytes, path.hopCount, ); @@ -722,7 +761,9 @@ class _ChatScreenState extends State { 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 { ), onTap: () async { await connector.setPathOverride( - widget.contact, + _resolveContact(connector), pathLen: -1, ); if (!context.mounted) return; @@ -1005,11 +1046,7 @@ class _ChatScreenState extends State { ); 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 { 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 { 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 { _retryMessage(message); }, ), - if (widget.contact.type == advTypeRoom) + if (_resolveContact(context.read()).type == + advTypeRoom) ListTile( leading: const Icon(Icons.chat), title: Text(context.l10n.contacts_openChat), @@ -1148,7 +1191,7 @@ class _ChatScreenState extends State { void _retryMessage(Message message) { final connector = Provider.of(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 { // 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 { message.text, ); final reactionText = 'r:$hash:$emojiIndex'; - connector.sendMessage(widget.contact, reactionText); + connector.sendMessage(_resolveContact(connector), reactionText); } } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a6739e1..7937398 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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 .contactsSearchText; _loadGroups(); _setupFrameListener(); + _clearAdvertNotifications(); + } + + void _clearAdvertNotifications() { + final connector = context.read(); + final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList(); + NotificationService().clearAdvertNotifications(contactIds); } @override diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 7ffec56..1dd3a5f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1509,6 +1509,23 @@ class _MapScreenState extends State { ); }, ), + 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), diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 694a616..b66ba51 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier { []; // Rolling buffer of recent ACK hashes final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) + final Map> _sendQueue = + {}; // contactPubKeyHex → ordered list of messageIds awaiting send + final Set _activeMessages = + {}; // messageIds currently in-flight (sent/retrying) + final Set _resolvedMessages = + {}; // messageIds already resolved (prevents double _onMessageResolved) final Map _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 _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(); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 95326d2..62d3796 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 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 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 clearAdvertNotifications(List contactIds) async { + if (!await _ensureInitialized()) return; + for (final id in contactIds) { + await _notifications.cancel(id: 'advert:$id'.hashCode); + } + } + + Future _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) // ───────────────────────────────────────────────────────────────── diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ce0c4f1..a86c1f6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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 savePathHistory( String contactPubKeyHex, @@ -122,4 +124,33 @@ class StorageService { final prefs = PrefsManager.instance; await prefs.remove(_repeaterPasswordsKey); } + + Future saveDeliveryObservations( + List observations, + ) async { + final prefs = PrefsManager.instance; + final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList()); + await prefs.setString(_deliveryObservationsKey, jsonStr); + } + + Future> 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)) + .toList(); + } catch (e) { + return []; + } + } + + Future clearDeliveryObservations() async { + final prefs = PrefsManager.instance; + await prefs.remove(_deliveryObservationsKey); + } } diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart new file mode 100644 index 0000000..d92ca64 --- /dev/null +++ b/lib/services/timeout_prediction_service.dart @@ -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 _observations = []; + LinearRegressor? _model; + List _activeFeatures = []; + int _observationsSinceLastTrain = 0; + final Map _contactStats = {}; + Timer? _persistTimer; + + TimeoutPredictionService(StorageService storage) : _storage = storage; + TimeoutPredictionService.noStorage() : _storage = null; + + int get observationCount => _observations.length; + bool get hasModel => _model != null; + + Future 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 = [ + (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 = []; + 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()); + } + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4084d9b..d2ea57e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.yaml b/pubspec.yaml index 82e4d9c..4831e67 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/services/ml_algo_sanity_test.dart b/test/services/ml_algo_sanity_test.dart new file mode 100644 index 0000000..427a8a6 --- /dev/null +++ b/test/services/ml_algo_sanity_test.dart @@ -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', + ); + } + }); +} diff --git a/test/services/timeout_prediction_service_test.dart b/test/services/timeout_prediction_service_test.dart new file mode 100644 index 0000000..dbd852d --- /dev/null +++ b/test/services/timeout_prediction_service_test.dart @@ -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(), + ); +}