From 7a2bb20bf732da4c7dbb8ef7f161c5b90d991c0e Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Sat, 7 Mar 2026 20:07:19 -0500 Subject: [PATCH 01/75] feat: Add TCP connection support and UI integration - Implemented TCP transport service for native platforms. - Added TCP connection screen with input fields for host and port. - Integrated TCP connection options into the scanner and USB screens. - Updated localization files for new TCP-related strings. - Added tests for TCP connection flow and error handling. - Enhanced USB screen to include TCP connection option. - Improved layout to ensure no overflow in narrow widths for scanner and USB screens. --- lib/connector/meshcore_connector.dart | 99 ++++++- lib/connector/meshcore_connector_tcp.dart | 37 +++ lib/connector/meshcore_connector_usb.dart | 3 + lib/l10n/app_bg.arb | 30 +- lib/l10n/app_de.arb | 30 +- lib/l10n/app_en.arb | 30 +- lib/l10n/app_es.arb | 30 +- lib/l10n/app_fr.arb | 30 +- lib/l10n/app_it.arb | 30 +- lib/l10n/app_localizations.dart | 84 ++++++ lib/l10n/app_localizations_bg.dart | 47 +++ lib/l10n/app_localizations_de.dart | 50 ++++ lib/l10n/app_localizations_en.dart | 47 +++ lib/l10n/app_localizations_es.dart | 47 +++ lib/l10n/app_localizations_fr.dart | 49 ++++ lib/l10n/app_localizations_it.dart | 48 ++++ lib/l10n/app_localizations_nl.dart | 48 ++++ lib/l10n/app_localizations_pl.dart | 49 ++++ lib/l10n/app_localizations_pt.dart | 49 ++++ lib/l10n/app_localizations_ru.dart | 48 ++++ lib/l10n/app_localizations_sk.dart | 47 +++ lib/l10n/app_localizations_sl.dart | 47 +++ lib/l10n/app_localizations_sv.dart | 47 +++ lib/l10n/app_localizations_uk.dart | 48 ++++ lib/l10n/app_localizations_zh.dart | 46 +++ lib/l10n/app_nl.arb | 30 +- lib/l10n/app_pl.arb | 30 +- lib/l10n/app_pt.arb | 30 +- lib/l10n/app_ru.arb | 30 +- lib/l10n/app_sk.arb | 30 +- lib/l10n/app_sl.arb | 30 +- lib/l10n/app_sv.arb | 30 +- lib/l10n/app_uk.arb | 30 +- lib/l10n/app_zh.arb | 30 +- lib/screens/scanner_screen.dart | 116 ++++---- lib/screens/tcp_screen.dart | 272 ++++++++++++++++++ lib/screens/usb_screen.dart | 105 ++++--- lib/services/tcp_transport_service.dart | 2 + .../tcp_transport_service_native.dart | 205 +++++++++++++ lib/services/tcp_transport_service_web.dart | 35 +++ test/screens/tcp_flow_test.dart | 170 +++++++++++ test/screens/usb_flow_test.dart | 83 ++++-- .../tcp_transport_service_native_test.dart | 136 +++++++++ 43 files changed, 2391 insertions(+), 123 deletions(-) create mode 100644 lib/connector/meshcore_connector_tcp.dart create mode 100644 lib/screens/tcp_screen.dart create mode 100644 lib/services/tcp_transport_service.dart create mode 100644 lib/services/tcp_transport_service_native.dart create mode 100644 lib/services/tcp_transport_service_web.dart create mode 100644 test/screens/tcp_flow_test.dart create mode 100644 test/services/tcp_transport_service_native_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 89aeca0..135299e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -22,6 +22,7 @@ import '../services/app_settings_service.dart'; import '../services/background_service.dart'; import '../services/notification_service.dart'; import 'meshcore_connector_usb.dart'; +import 'meshcore_connector_tcp.dart'; import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; @@ -86,7 +87,7 @@ enum MeshCoreConnectionState { disconnecting, } -enum MeshCoreTransportType { bluetooth, usb } +enum MeshCoreTransportType { bluetooth, usb, tcp } class RepeaterBatterySnapshot { final int millivolts; @@ -116,6 +117,8 @@ class MeshCoreConnector extends ChangeNotifier { bool _manualDisconnect = false; final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); StreamSubscription? _usbFrameSubscription; + final MeshCoreTcpManager _tcpManager = MeshCoreTcpManager(); + StreamSubscription? _tcpFrameSubscription; MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; final List _scanResults = []; @@ -255,6 +258,10 @@ class MeshCoreConnector extends ChangeNotifier { bool get isUsbTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.usb; + String? get activeTcpEndpoint => _tcpManager.activeEndpoint; + bool get isTcpTransportConnected => + _state == MeshCoreConnectionState.connected && + _activeTransport == MeshCoreTransportType.tcp; String get deviceDisplayName { if (_selfName != null && _selfName!.isNotEmpty) { @@ -659,6 +666,7 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _usbManager.setDebugLogService(_appDebugLogService); + _tcpManager.setDebugLogService(_appDebugLogService); // Initialize notification service _notificationService.initialize(); @@ -964,6 +972,70 @@ class MeshCoreConnector extends ChangeNotifier { } } + Future connectTcp({required String host, required int port}) async { + if (_state == MeshCoreConnectionState.connecting || + _state == MeshCoreConnectionState.connected) { + _appDebugLogService?.warn( + 'connectTcp ignored: already $_state', + tag: 'TCP', + ); + return; + } + + _appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP'); + + await stopScan(); + _cancelReconnectTimer(); + _manualDisconnect = false; + _resetConnectionHandshakeState(); + _activeTransport = MeshCoreTransportType.tcp; + _setState(MeshCoreConnectionState.connecting); + + try { + await _tcpFrameSubscription?.cancel(); + _tcpFrameSubscription = null; + await _tcpManager.connect(host: host, port: port); + notifyListeners(); + + await Future.delayed(const Duration(milliseconds: 200)); + _tcpFrameSubscription = _tcpManager.frameStream.listen( + _handleFrame, + onError: (error, stackTrace) { + _appDebugLogService?.error('TCP transport error: $error', tag: 'TCP'); + unawaited(disconnect(manual: false)); + }, + onDone: () { + _appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP'); + unawaited(disconnect(manual: false)); + }, + ); + + _setState(MeshCoreConnectionState.connected); + _pendingInitialChannelSync = true; + await _requestDeviceInfo(); + _startBatteryPolling(); + + var gotSelfInfo = await _waitForSelfInfo( + timeout: const Duration(seconds: 3), + ); + if (!gotSelfInfo) { + await refreshDeviceInfo(); + gotSelfInfo = await _waitForSelfInfo( + timeout: const Duration(seconds: 3), + ); + } + if (!gotSelfInfo) { + throw StateError('Timed out waiting for SELF_INFO during TCP connect'); + } + + await syncTime(); + } catch (error) { + _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); + await disconnect(manual: false); + rethrow; + } + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { @@ -1230,6 +1302,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get _shouldGateInitialChannelSync => _activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp || (_activeTransport == MeshCoreTransportType.bluetooth && PlatformInfo.isWeb); @@ -1276,9 +1349,11 @@ class MeshCoreConnector extends ChangeNotifier { Future disconnect({bool manual = true}) async { if (_state == MeshCoreConnectionState.disconnecting) return; final transportAtDisconnect = _activeTransport; - final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb - ? 'USB' - : 'BLE'; + final transportLabel = switch (transportAtDisconnect) { + MeshCoreTransportType.bluetooth => 'BLE', + MeshCoreTransportType.usb => 'USB', + MeshCoreTransportType.tcp => 'TCP', + }; _appDebugLogService?.info( 'Starting disconnect transport=$transportLabel manual=$manual', @@ -1298,6 +1373,9 @@ class MeshCoreConnector extends ChangeNotifier { await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; await _usbManager.disconnect(); + await _tcpFrameSubscription?.cancel(); + _tcpFrameSubscription = null; + await _tcpManager.disconnect(); await _notifySubscription?.cancel(); _notifySubscription = null; @@ -1379,6 +1457,8 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await _usbManager.write(data); + } else if (_activeTransport == MeshCoreTransportType.tcp) { + await _tcpManager.write(data); } else { if (_rxCharacteristic == null) { throw Exception("MeshCore RX characteristic not available"); @@ -2338,7 +2418,8 @@ class MeshCoreConnector extends ChangeNotifier { } if (_pendingDeferredChannelSyncAfterContacts && (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb)) { + _activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp)) { _pendingDeferredChannelSyncAfterContacts = false; _pendingInitialChannelSync = false; unawaited(getChannels()); @@ -2505,14 +2586,16 @@ class MeshCoreConnector extends ChangeNotifier { if (PlatformInfo.isWeb && _activeTransport == MeshCoreTransportType.bluetooth) { _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb) { + } else if (_activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp) { _pendingDeferredChannelSyncAfterContacts = true; getContacts(); } else { getContacts(); } if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb) { + _activeTransport != MeshCoreTransportType.usb && + _activeTransport != MeshCoreTransportType.tcp) { _maybeStartInitialChannelSync(); } } @@ -4274,12 +4357,14 @@ class MeshCoreConnector extends ChangeNotifier { _scanSubscription?.cancel(); _connectionSubscription?.cancel(); _usbFrameSubscription?.cancel(); + _tcpFrameSubscription?.cancel(); _notifySubscription?.cancel(); _notifyListenersTimer?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); _receivedFramesController.close(); _usbManager.dispose(); + _tcpManager.dispose(); // Flush pending unread writes before disposal _unreadStore.flush(); diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart new file mode 100644 index 0000000..01c2b92 --- /dev/null +++ b/lib/connector/meshcore_connector_tcp.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +import '../services/app_debug_log_service.dart'; +import '../services/tcp_transport_service.dart'; + +class MeshCoreTcpManager { + final TcpTransportService _service = TcpTransportService(); + AppDebugLogService? _debugLog; + + String? get activeEndpoint => _service.activeEndpoint; + bool get isConnected => _service.isConnected; + Stream get frameStream => _service.frameStream; + + void setDebugLogService(AppDebugLogService? service) { + _debugLog = service; + _service.setDebugLogService(service); + } + + Future connect({required String host, required int port}) async { + _debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP'); + await _service.connect(host: host, port: port); + } + + Future disconnect() async { + if (!_service.isConnected && _service.activeEndpoint == null) { + return; + } + _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); + await _service.disconnect(); + } + + Future write(Uint8List data) => _service.write(data); + + void dispose() { + _service.dispose(); + } +} diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart index 376ae1a..74e7355 100644 --- a/lib/connector/meshcore_connector_usb.dart +++ b/lib/connector/meshcore_connector_usb.dart @@ -53,6 +53,9 @@ class MeshCoreUsbManager { } Future disconnect() async { + if (!_service.isConnected && _activePortKey == null) { + return; + } _debugLog?.info('UsbManager.disconnect', tag: 'USB'); await _service.disconnect(); _activePortKey = null; diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 94d8997..972d376 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbStatus_notConnected": "Изберете USB устройство", "usbStatus_searching": "Търсене на USB устройства...", - "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." + "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Свържете се чрез TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP адрес", + "tcpPortLabel": "Пристанище", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Въведете крайната точка и свържете се.", + "tcpStatus_connecting": "Свързване към TCP крайния пункт...", + "tcpStatus_connectingTo": "Свързване към {endpoint}...", + "tcpErrorHostRequired": "Необходим е IP адрес.", + "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", + "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", + "tcpErrorTimedOut": "Връзката TCP изтекла.", + "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9ba0f51..d6ff0b0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1887,5 +1887,33 @@ "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", - "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." + "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "IP-Adresse", + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Verbinden über TCP", + "tcpPortLabel": "Hafen", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.", + "tcpStatus_connecting": "Verbindung zum TCP-Endpunkt hergestellt...", + "tcpStatus_connectingTo": "Verbindung zu {endpoint}...", + "tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.", + "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", + "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", + "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", + "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2605628..544d574 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -49,6 +49,34 @@ "scanner_title": "MeshCore Open", "connectionChoiceUsbLabel": "USB", "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Connect over TCP", + "tcpHostLabel": "IP Address", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Enter endpoint and connect", + "tcpStatus_connecting": "Connecting to TCP endpoint...", + "tcpStatus_connectingTo": "Connecting to {endpoint}...", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "tcpErrorHostRequired": "IP address is required.", + "tcpErrorPortInvalid": "Port must be between 1 and 65535.", + "tcpErrorUnsupported": "TCP transport is not supported on this platform.", + "tcpErrorTimedOut": "TCP connection timed out.", + "tcpConnectionFailed": "TCP connection failed: {error}", + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "usbScreenTitle": "Connect over USB", "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", "usbScreenStatus": "Select a USB device", @@ -1898,4 +1926,4 @@ "discoveredContacts_deleteContact": "Delete Discovered Contact", "discoveredContacts_deleteContactAll": "Delete All Discovered Contacts", "discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?" -} \ No newline at end of file +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9b791d3..dcad901 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1887,5 +1887,33 @@ "usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_notConnected": "Seleccione un dispositivo USB", "usbConnectionFailed": "Error al conectar mediante USB: {error}", - "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." + "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpScreenTitle": "Establecer conexión a través de TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "Dirección IP", + "tcpPortLabel": "Puerto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Ingrese la dirección final y conecte.", + "tcpStatus_connecting": "Conectándose al punto final TCP...", + "tcpStatus_connectingTo": "Conectándose a {endpoint}...", + "tcpErrorHostRequired": "Se requiere la dirección IP.", + "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", + "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}" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a7bedc9..e934b7e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Échec de la connexion USB : {error}", "usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_searching": "Recherche de périphériques USB...", - "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." + "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Adresse IP", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Établir une connexion via TCP", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.", + "tcpStatus_connecting": "Connexion au point de terminaison TCP...", + "tcpStatus_connectingTo": "Connexion à {endpoint}...", + "tcpErrorHostRequired": "Une adresse IP est obligatoire.", + "tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", + "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}" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 423ff40..30b19b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Errore nella connessione USB: {error}", "usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_connecting": "Connessione al dispositivo USB...", - "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." + "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Indirizzo IP", + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Stabilire una connessione tramite TCP", + "tcpPortLabel": "Porto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.", + "tcpStatus_connecting": "Connessione al punto finale TCP...", + "tcpStatus_connectingTo": "Connessione a {endpoint}...", + "tcpErrorHostRequired": "È necessario fornire un indirizzo IP.", + "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", + "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", + "tcpErrorTimedOut": "La connessione TCP è scaduta.", + "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d3f86b..536d7fb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -334,6 +334,90 @@ abstract class AppLocalizations { /// **'Bluetooth'** String get connectionChoiceBluetoothLabel; + /// No description provided for @connectionChoiceTcpLabel. + /// + /// In en, this message translates to: + /// **'TCP'** + String get connectionChoiceTcpLabel; + + /// No description provided for @tcpScreenTitle. + /// + /// In en, this message translates to: + /// **'Connect over TCP'** + String get tcpScreenTitle; + + /// No description provided for @tcpHostLabel. + /// + /// In en, this message translates to: + /// **'IP Address'** + String get tcpHostLabel; + + /// No description provided for @tcpHostHint. + /// + /// In en, this message translates to: + /// **'192.168.40.10'** + String get tcpHostHint; + + /// No description provided for @tcpPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get tcpPortLabel; + + /// No description provided for @tcpPortHint. + /// + /// In en, this message translates to: + /// **'5000'** + String get tcpPortHint; + + /// No description provided for @tcpStatus_notConnected. + /// + /// In en, this message translates to: + /// **'Enter endpoint and connect'** + String get tcpStatus_notConnected; + + /// No description provided for @tcpStatus_connecting. + /// + /// In en, this message translates to: + /// **'Connecting to TCP endpoint...'** + String get tcpStatus_connecting; + + /// No description provided for @tcpStatus_connectingTo. + /// + /// In en, this message translates to: + /// **'Connecting to {endpoint}...'** + String tcpStatus_connectingTo(String endpoint); + + /// No description provided for @tcpErrorHostRequired. + /// + /// In en, this message translates to: + /// **'IP address is required.'** + String get tcpErrorHostRequired; + + /// No description provided for @tcpErrorPortInvalid. + /// + /// In en, this message translates to: + /// **'Port must be between 1 and 65535.'** + String get tcpErrorPortInvalid; + + /// No description provided for @tcpErrorUnsupported. + /// + /// In en, this message translates to: + /// **'TCP transport is not supported on this platform.'** + String get tcpErrorUnsupported; + + /// No description provided for @tcpErrorTimedOut. + /// + /// In en, this message translates to: + /// **'TCP connection timed out.'** + String get tcpErrorTimedOut; + + /// No description provided for @tcpConnectionFailed. + /// + /// In en, this message translates to: + /// **'TCP connection failed: {error}'** + String tcpConnectionFailed(String error); + /// No description provided for @usbScreenTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 356106e..2ea0e0f 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -117,6 +117,53 @@ class AppLocalizationsBg extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Свържете се чрез TCP'; + + @override + String get tcpHostLabel => 'IP адрес'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Пристанище'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.'; + + @override + String get tcpStatus_connecting => 'Свързване към TCP крайния пункт...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Свързване към $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необходим е IP адрес.'; + + @override + String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.'; + + @override + String get tcpErrorUnsupported => + 'Транспортът чрез TCP не се поддържа на тази платформа.'; + + @override + String get tcpErrorTimedOut => 'Връзката TCP изтекла.'; + + @override + String tcpConnectionFailed(String error) { + return 'Неуспешно е установено TCP връзката: $error'; + } + @override String get usbScreenTitle => 'Свържете се чрез USB'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6353f35..495b37b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -117,6 +117,56 @@ class AppLocalizationsDe extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Verbinden über TCP'; + + @override + String get tcpHostLabel => 'IP-Adresse'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Hafen'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => + 'Geben Sie den Endpunkt ein und verbinden Sie sich.'; + + @override + String get tcpStatus_connecting => + 'Verbindung zum TCP-Endpunkt hergestellt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Verbindung zu $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.'; + + @override + String get tcpErrorPortInvalid => + 'Die Portnummer muss zwischen 1 und 65535 liegen.'; + + @override + String get tcpErrorUnsupported => + 'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.'; + + @override + String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.'; + + @override + String tcpConnectionFailed(String error) { + return 'Fehler beim TCP-Verbindungsaufbau: $error'; + } + @override String get usbScreenTitle => 'Verbinden über USB'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c20df7..c20a6df 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -117,6 +117,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Connect over TCP'; + + @override + String get tcpHostLabel => 'IP Address'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Enter endpoint and connect'; + + @override + String get tcpStatus_connecting => 'Connecting to TCP endpoint...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connecting to $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'IP address is required.'; + + @override + String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.'; + + @override + String get tcpErrorUnsupported => + 'TCP transport is not supported on this platform.'; + + @override + String get tcpErrorTimedOut => 'TCP connection timed out.'; + + @override + String tcpConnectionFailed(String error) { + return 'TCP connection failed: $error'; + } + @override String get usbScreenTitle => 'Connect over USB'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index eecbd48..56bba0e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -117,6 +117,53 @@ class AppLocalizationsEs extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Establecer conexión a través de TCP'; + + @override + String get tcpHostLabel => 'Dirección IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Puerto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.'; + + @override + String get tcpStatus_connecting => 'Conectándose al punto final TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Conectándose a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Se requiere la dirección IP.'; + + @override + String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.'; + + @override + String get tcpErrorUnsupported => + 'El protocolo de transporte TCP no está soportado en esta plataforma.'; + + @override + String get tcpErrorTimedOut => 'La conexión TCP ha caducado.'; + + @override + String tcpConnectionFailed(String error) { + return 'Error en la conexión TCP: $error'; + } + @override String get usbScreenTitle => 'Conecte mediante USB'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5cabc86..0dd88f7 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -117,6 +117,55 @@ class AppLocalizationsFr extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Établir une connexion via TCP'; + + @override + String get tcpHostLabel => 'Adresse IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => + 'Entrez l\'adresse de destination et connectez-vous.'; + + @override + String get tcpStatus_connecting => 'Connexion au point de terminaison TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connexion à $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.'; + + @override + String get tcpErrorPortInvalid => + 'La taille du port doit être comprise entre 1 et 65535.'; + + @override + String get tcpErrorUnsupported => + 'Le protocole TCP n\'est pas pris en charge sur cette plateforme.'; + + @override + String get tcpErrorTimedOut => 'La connexion TCP a expiré.'; + + @override + String tcpConnectionFailed(String error) { + return 'Échec de la connexion TCP : $error'; + } + @override String get usbScreenTitle => 'Connectez via USB'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d170540..ce7b658 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -117,6 +117,54 @@ class AppLocalizationsIt extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Stabilire una connessione tramite TCP'; + + @override + String get tcpHostLabel => 'Indirizzo IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Porto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.'; + + @override + String get tcpStatus_connecting => 'Connessione al punto finale TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connessione a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.'; + + @override + String get tcpErrorPortInvalid => + 'La dimensione della porta deve essere compresa tra 1 e 65535.'; + + @override + String get tcpErrorUnsupported => + 'Il protocollo TCP non è supportato su questa piattaforma.'; + + @override + String get tcpErrorTimedOut => 'La connessione TCP è scaduta.'; + + @override + String tcpConnectionFailed(String error) { + return 'Impossibile stabilire la connessione TCP: $error'; + } + @override String get usbScreenTitle => 'Connessione tramite USB'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 323ba34..70dbfe7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -117,6 +117,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Verbind via TCP'; + + @override + String get tcpHostLabel => 'IP-adres'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Haven'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind'; + + @override + String get tcpStatus_connecting => 'Verbinding maken met TCP-eindpunt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Verbinding maken met $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Een IP-adres is vereist.'; + + @override + String get tcpErrorPortInvalid => + 'De poortwaarde moet tussen 1 en 65535 liggen.'; + + @override + String get tcpErrorUnsupported => + 'TCP-transport wordt niet ondersteund op deze platform.'; + + @override + String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.'; + + @override + String tcpConnectionFailed(String error) { + return 'Verbinding met TCP mislukt: $error'; + } + @override String get usbScreenTitle => 'Verbind via USB'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 9175c3e..121c5fe 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -117,6 +117,55 @@ class AppLocalizationsPl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP'; + + @override + String get tcpHostLabel => 'Adres IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz'; + + @override + String get tcpStatus_connecting => 'Połączenie z punktem TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Połączenie z $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Wymagana jest adresa IP.'; + + @override + String get tcpErrorPortInvalid => + 'Numer portu musi mieścić się w zakresie od 1 do 65535.'; + + @override + String get tcpErrorUnsupported => + 'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.'; + + @override + String get tcpErrorTimedOut => + 'Połączenie TCP zakończyło się bez powodzenia.'; + + @override + String tcpConnectionFailed(String error) { + return 'Błąd połączenia TCP: $error'; + } + @override String get usbScreenTitle => 'Połącz przez USB'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ff09213..9545143 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -117,6 +117,55 @@ class AppLocalizationsPt extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Estabelecer conexão via TCP'; + + @override + String get tcpHostLabel => 'Endereço IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Porto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.'; + + @override + String get tcpStatus_connecting => + 'Conectando ao ponto de extremidade TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Conectando a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.'; + + @override + String get tcpErrorPortInvalid => + 'O valor do porto deve estar entre 1 e 65535.'; + + @override + String get tcpErrorUnsupported => + 'O protocolo TCP não é suportado nesta plataforma.'; + + @override + String get tcpErrorTimedOut => 'A conexão TCP expirou.'; + + @override + String tcpConnectionFailed(String error) { + return 'Falha na conexão TCP: $error'; + } + @override String get usbScreenTitle => 'Conecte via USB'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 69a5891..a0af1b3 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -117,6 +117,54 @@ class AppLocalizationsRu extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Установить соединение по протоколу TCP'; + + @override + String get tcpHostLabel => 'IP-адрес'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Порт'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Введите адрес и подключитесь.'; + + @override + String get tcpStatus_connecting => 'Установление соединения с TCP-портом...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Подключение к $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.'; + + @override + String get tcpErrorPortInvalid => + 'Порт должен находиться в диапазоне от 1 до 65535.'; + + @override + String get tcpErrorUnsupported => + 'Протокол TCP не поддерживается на этой платформе.'; + + @override + String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.'; + + @override + String tcpConnectionFailed(String error) { + return 'Не удалось установить соединение TCP: $error'; + } + @override String get usbScreenTitle => 'Подключение через USB'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d0e75b0..f29c1e6 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -117,6 +117,53 @@ class AppLocalizationsSk extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP'; + + @override + String get tcpHostLabel => 'IP adresa'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Pri항'; + + @override + String get tcpPortHint => '5 000'; + + @override + String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.'; + + @override + String get tcpStatus_connecting => 'Pripojenie k TCP endpointu...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Pripojenie k $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.'; + + @override + String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.'; + + @override + String get tcpErrorUnsupported => + 'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.'; + + @override + String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.'; + + @override + String tcpConnectionFailed(String error) { + return 'Neúspešné vytvorenie TCP spojenia: $error'; + } + @override String get usbScreenTitle => 'Pripojte cez USB'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 21e3d9d..f4c6df0 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -117,6 +117,53 @@ class AppLocalizationsSl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Komunicirajte preko protokola TCP'; + + @override + String get tcpHostLabel => 'IP naslov'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Pril'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite'; + + @override + String get tcpStatus_connecting => 'Povezava z TCP koncem...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Povezava z $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Potrebna je IP-naslov.'; + + @override + String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.'; + + @override + String get tcpErrorUnsupported => + 'Transport preko protokola TCP ni podprt na tej platformi.'; + + @override + String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.'; + + @override + String tcpConnectionFailed(String error) { + return 'Napaka pri povezavi TCP: $error'; + } + @override String get usbScreenTitle => 'Povežite preko USB'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 5951fae..140e3eb 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -117,6 +117,53 @@ class AppLocalizationsSv extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Anslut via TCP'; + + @override + String get tcpHostLabel => 'IP-adress'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Hamn'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Ange slutpunkt och anslut'; + + @override + String get tcpStatus_connecting => 'Anslutning till TCP-slutpunkt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Anslutning till $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'IP-adress krävs.'; + + @override + String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.'; + + @override + String get tcpErrorUnsupported => + 'TCP-transport fungerar inte på denna plattform.'; + + @override + String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.'; + + @override + String tcpConnectionFailed(String error) { + return 'Fel vid TCP-anslutning: $error'; + } + @override String get usbScreenTitle => 'Anslut via USB'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b8fd60a..5b28e57 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -117,6 +117,54 @@ class AppLocalizationsUk extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'З\'єднатися через протокол TCP'; + + @override + String get tcpHostLabel => 'IP-адреса'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Порт'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться'; + + @override + String get tcpStatus_connecting => 'Підключення до TCP-кінцевої точки...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Підключення до $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.'; + + @override + String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.'; + + @override + String get tcpErrorUnsupported => + 'Транспорт TCP не підтримується на цій платформі.'; + + @override + String get tcpErrorTimedOut => + 'З\'єднання TCP завершилося через закінчення часу очікування.'; + + @override + String tcpConnectionFailed(String error) { + return 'Не вдалося встановити з\'єднання TCP: $error'; + } + @override String get usbScreenTitle => 'Підключити через USB'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 27e6c21..2c0034c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -117,6 +117,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => '蓝牙'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => '通过 TCP 连接'; + + @override + String get tcpHostLabel => 'IP地址'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => '港'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => '输入目标地址,然后连接'; + + @override + String get tcpStatus_connecting => '连接到 TCP 终点...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return '连接到 $endpoint...'; + } + + @override + String get tcpErrorHostRequired => '需要提供IP地址。'; + + @override + String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。'; + + @override + String get tcpErrorUnsupported => '此平台不支持 TCP 传输。'; + + @override + String get tcpErrorTimedOut => 'TCP 连接超时。'; + + @override + String tcpConnectionFailed(String error) { + return 'TCP 连接失败:$error'; + } + @override String get usbScreenTitle => '通过USB连接'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 94df130..29b9eca 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_searching": "Zoeken naar USB-apparaten...", - "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." + "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpScreenTitle": "Verbind via TCP", + "tcpHostLabel": "IP-adres", + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Haven", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Voer het eindpunt in en verbind", + "tcpStatus_connecting": "Verbinding maken met TCP-eindpunt...", + "tcpStatus_connectingTo": "Verbinding maken met {endpoint}...", + "tcpErrorHostRequired": "Een IP-adres is vereist.", + "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", + "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", + "tcpErrorTimedOut": "De TCP-verbinding is verlopen.", + "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d020e0e..7576cf7 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1859,5 +1859,33 @@ "usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_notConnected": "Wybierz urządzenie USB", "usbConnectionFailed": "Błąd połączenia USB: {error}", - "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." + "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Połącz się za pomocą protokołu TCP", + "tcpHostLabel": "Adres IP", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Wprowadź adres URL i połącz", + "tcpStatus_connecting": "Połączenie z punktem TCP...", + "tcpStatus_connectingTo": "Połączenie z {endpoint}...", + "tcpErrorHostRequired": "Wymagana jest adresa IP.", + "tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", + "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}" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d52cb41..428772c 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Selecione um dispositivo USB", "usbConnectionFailed": "Falha na conexão USB: {error}", "usbStatus_connecting": "Conectando ao dispositivo USB...", - "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." + "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Endereço IP", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Estabelecer conexão via TCP", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Porto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Insira o endereço final e conecte-se.", + "tcpStatus_connecting": "Conectando ao ponto de extremidade TCP...", + "tcpStatus_connectingTo": "Conectando a {endpoint}...", + "tcpErrorHostRequired": "É necessário fornecer um endereço IP.", + "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", + "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", + "tcpErrorTimedOut": "A conexão TCP expirou.", + "tcpConnectionFailed": "Falha na conexão TCP: {error}" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 92fd55e..f42e959 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1099,5 +1099,33 @@ "usbStatus_connecting": "Подключение к USB-устройству...", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbStatus_notConnected": "Выберите USB-устройство", - "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." + "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP-адрес", + "tcpScreenTitle": "Установить соединение по протоколу TCP", + "tcpPortLabel": "Порт", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Введите адрес и подключитесь.", + "tcpStatus_connecting": "Установление соединения с TCP-портом...", + "tcpStatus_connectingTo": "Подключение к {endpoint}...", + "tcpErrorHostRequired": "Необходимо указать IP-адрес.", + "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", + "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", + "tcpErrorTimedOut": "Соединение TCP не удалось установить.", + "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 141147c..57f95ab 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_connecting": "Pripojenie k USB zariadeniu...", - "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." + "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP adresa", + "tcpScreenTitle": "Spojte sa pomocou protokolu TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Pri항", + "tcpPortHint": "5 000", + "tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.", + "tcpStatus_connecting": "Pripojenie k TCP endpointu...", + "tcpStatus_connectingTo": "Pripojenie k {endpoint}...", + "tcpErrorHostRequired": "Je potrebné zadať IP adresu.", + "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", + "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}" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 12529d6..252c7d7 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1859,5 +1859,33 @@ "usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_searching": "Iskanje USB naprav...", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", - "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." + "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP naslov", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Komunicirajte preko protokola TCP", + "tcpPortLabel": "Pril", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Vnesite končni naslov in se povežite", + "tcpStatus_connecting": "Povezava z TCP koncem...", + "tcpStatus_connectingTo": "Povezava z {endpoint}...", + "tcpErrorHostRequired": "Potrebna je IP-naslov.", + "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", + "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", + "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", + "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index f7615df..349a4d3 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Välj en USB-enhet", "usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbStatus_searching": "Söker efter USB-enheter...", - "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." + "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP-adress", + "tcpScreenTitle": "Anslut via TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Hamn", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Ange slutpunkt och anslut", + "tcpStatus_connecting": "Anslutning till TCP-slutpunkt...", + "tcpStatus_connectingTo": "Anslutning till {endpoint}...", + "tcpErrorHostRequired": "IP-adress krävs.", + "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", + "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", + "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", + "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 7794098..8d1ea80 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Виберіть пристрій USB", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbStatus_connecting": "Підключення до USB-пристрою...", - "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." + "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP-адреса", + "tcpScreenTitle": "З'єднатися через протокол TCP", + "tcpPortLabel": "Порт", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Введіть кінцеву точку та підключіться", + "tcpStatus_connecting": "Підключення до TCP-кінцевої точки...", + "tcpStatus_connectingTo": "Підключення до {endpoint}...", + "tcpErrorHostRequired": "Необхідно вказати IP-адресу.", + "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", + "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", + "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", + "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dfc8e64..1df60a7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1864,5 +1864,33 @@ "usbStatus_connecting": "连接USB设备...", "usbStatus_notConnected": "选择一个 USB 设备", "usbConnectionFailed": "USB 连接失败:{error}", - "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" + "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "IP地址", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "通过 TCP 连接", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "港", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "输入目标地址,然后连接", + "tcpStatus_connecting": "连接到 TCP 终点...", + "tcpStatus_connectingTo": "连接到 {endpoint}...", + "tcpErrorHostRequired": "需要提供IP地址。", + "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", + "tcpErrorUnsupported": "此平台不支持 TCP 传输。", + "tcpErrorTimedOut": "TCP 连接超时。", + "tcpConnectionFailed": "TCP 连接失败:{error}" } diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 45fd3fb..986a598 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -10,6 +10,7 @@ import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import 'contacts_screen.dart'; +import 'tcp_screen.dart'; import 'usb_screen.dart'; /// Screen for scanning and connecting to MeshCore devices @@ -125,61 +126,78 @@ class _ScannerScreenState extends State { connector.state == MeshCoreConnectionState.scanning; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final usbSupported = PlatformInfo.supportsUsbSerial; + final tcpSupported = !PlatformInfo.isWeb; return SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (usbSupported) + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (usbSupported) + FloatingActionButton.extended( + onPressed: () { + appLogger.info( + 'USB selected, opening UsbScreen', + tag: 'ScannerScreen', + ); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UsbScreen()), + ); + }, + heroTag: 'scanner_usb_action', + icon: const Icon(Icons.usb), + label: Text(context.l10n.connectionChoiceUsbLabel), + ), + if (usbSupported) const SizedBox(width: 12), + if (tcpSupported) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TcpScreen()), + ); + }, + heroTag: 'scanner_tcp_action', + icon: const Icon(Icons.lan), + label: Text(context.l10n.connectionChoiceTcpLabel), + ), + if (tcpSupported) const SizedBox(width: 12), FloatingActionButton.extended( - onPressed: () { - appLogger.info( - 'USB selected, opening UsbScreen', - tag: 'ScannerScreen', - ); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const UsbScreen()), - ); - }, - heroTag: 'scanner_usb_action', - icon: const Icon(Icons.usb), - label: Text(context.l10n.connectionChoiceUsbLabel), + heroTag: 'scanner_ble_action', + onPressed: isBluetoothOff + ? null + : () { + if (isScanning) { + connector.stopScan(); + } else { + unawaited( + connector.startScan().catchError((e) { + appLogger.warn( + 'startScan error: $e', + tag: 'ScannerScreen', + ); + }), + ); + } + }, + icon: isScanning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bluetooth_searching), + label: Text( + isScanning + ? context.l10n.scanner_stop + : context.l10n.scanner_scan, + ), ), - if (usbSupported) const SizedBox(width: 12), - FloatingActionButton.extended( - heroTag: 'scanner_ble_action', - onPressed: isBluetoothOff - ? null - : () { - if (isScanning) { - connector.stopScan(); - } else { - unawaited( - connector.startScan().catchError((e) { - appLogger.warn( - 'startScan error: $e', - tag: 'ScannerScreen', - ); - }), - ); - } - }, - icon: isScanning - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bluetooth_searching), - label: Text( - isScanning - ? context.l10n.scanner_stop - : context.l10n.scanner_scan, - ), - ), - ], + ], + ), ), ); }, diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart new file mode 100644 index 0000000..f3702c3 --- /dev/null +++ b/lib/screens/tcp_screen.dart @@ -0,0 +1,272 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; +import '../utils/platform_info.dart'; +import '../widgets/adaptive_app_bar_title.dart'; +import 'contacts_screen.dart'; +import 'usb_screen.dart'; + +class TcpScreen extends StatefulWidget { + const TcpScreen({super.key}); + + @override + State createState() => _TcpScreenState(); +} + +class _TcpScreenState extends State { + late final TextEditingController _hostController; + late final TextEditingController _portController; + late final MeshCoreConnector _connector; + late final VoidCallback _connectionListener; + bool _navigatedToContacts = false; + + @override + void initState() { + super.initState(); + _hostController = TextEditingController(text: '192.168.40.10'); + _portController = TextEditingController(text: '5000'); + _connector = context.read(); + + _connectionListener = () { + if (!mounted) return; + if (_connector.state == MeshCoreConnectionState.disconnected) { + _navigatedToContacts = false; + } + if (_connector.state == MeshCoreConnectionState.connected && + _connector.isTcpTransportConnected && + !_navigatedToContacts) { + _navigatedToContacts = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } + }; + _connector.addListener(_connectionListener); + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _connector.removeListener(_connectionListener); + if (!_navigatedToContacts && + _connector.activeTransport == MeshCoreTransportType.tcp && + _connector.state != MeshCoreConnectionState.disconnected) { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_connector.disconnect(manual: true)); + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle), + centerTitle: true, + ), + body: SafeArea( + top: false, + child: Consumer( + builder: (context, connector, child) { + final isConnecting = + connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.tcp; + return Column( + children: [ + _buildStatusBar(context, connector), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _hostController, + decoration: InputDecoration( + labelText: context.l10n.tcpHostLabel, + hintText: context.l10n.tcpHostHint, + border: const OutlineInputBorder(), + ), + enabled: !isConnecting, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + TextField( + controller: _portController, + decoration: InputDecoration( + labelText: context.l10n.tcpPortLabel, + hintText: context.l10n.tcpPortHint, + border: const OutlineInputBorder(), + ), + enabled: !isConnecting, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isConnecting ? null : _connectTcp, + icon: isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.lan), + label: Text( + isConnecting + ? context.l10n.scanner_connecting + : context.l10n.common_connect, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + bottomNavigationBar: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (PlatformInfo.supportsUsbSerial) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const UsbScreen()), + ); + }, + heroTag: 'tcp_usb_action', + icon: const Icon(Icons.usb), + label: Text(context.l10n.connectionChoiceUsbLabel), + ), + if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12), + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).maybePop(); + }, + heroTag: 'tcp_ble_action', + icon: const Icon(Icons.bluetooth), + label: Text(context.l10n.connectionChoiceBluetoothLabel), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + String statusText; + Color statusColor; + + if (connector.isTcpTransportConnected) { + statusText = l10n.scanner_connectedTo( + connector.activeTcpEndpoint ?? 'TCP', + ); + statusColor = Colors.green; + } else if (connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + statusText = l10n.tcpStatus_connectingTo( + '${_hostController.text}:${_portController.text}', + ); + statusColor = Colors.orange; + } else if (connector.state == MeshCoreConnectionState.disconnecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + statusText = l10n.scanner_disconnecting; + statusColor = Colors.orange; + } else { + statusText = l10n.tcpStatus_notConnected; + statusColor = Colors.grey; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: statusColor.withValues(alpha: 0.1), + child: Row( + children: [ + Icon(Icons.circle, size: 12, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } + + Future _connectTcp() async { + if (_connector.state != MeshCoreConnectionState.disconnected) return; + + final host = _hostController.text.trim(); + final parsedPort = int.tryParse(_portController.text.trim()); + if (host.isEmpty) { + _showError(context.l10n.tcpErrorHostRequired); + return; + } + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + _showError(context.l10n.tcpErrorPortInvalid); + return; + } + + try { + await _connector.connectTcp(host: host, port: parsedPort); + } catch (error) { + if (!mounted) return; + _showError(_friendlyErrorMessage(error)); + } + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); + } + + String _friendlyErrorMessage(Object error) { + if (error is UnsupportedError) { + return context.l10n.tcpErrorUnsupported; + } + if (error is TimeoutException) { + return context.l10n.tcpErrorTimedOut; + } + if (error is StateError) { + return context.l10n.tcpConnectionFailed(error.message); + } + if (error is ArgumentError) { + return context.l10n.tcpConnectionFailed( + error.message?.toString() ?? error.toString(), + ); + } + return context.l10n.tcpConnectionFailed(error.toString()); + } +} diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index e230a54..0cc9007 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; import 'contacts_screen.dart'; import 'scanner_screen.dart'; +import 'tcp_screen.dart'; class UsbScreen extends StatefulWidget { const UsbScreen({super.key}); @@ -111,41 +112,68 @@ class _UsbScreenState extends State { PlatformInfo.isWeb || PlatformInfo.isAndroid || PlatformInfo.isIOS; + final showTcp = !PlatformInfo.isWeb; return SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (showBle) - FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const ScannerScreen(), - ), - ); - }, - heroTag: 'usb_ble_action', - icon: const Icon(Icons.bluetooth), - label: Text(context.l10n.connectionChoiceBluetoothLabel), - ), - if (showBle) const SizedBox(width: 12), - if (!_supportsHotPlug) - FloatingActionButton.extended( - onPressed: isLoading ? null : _loadPorts, - heroTag: 'usb_refresh_action', - icon: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - label: Text(context.l10n.repeater_refresh), - ), - ], + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showTcp) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const TcpScreen()), + ); + }, + heroTag: 'usb_tcp_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: const Icon(Icons.lan), + label: Text(context.l10n.connectionChoiceTcpLabel), + ), + if (showTcp && showBle) const SizedBox(width: 12), + if (showBle) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => const ScannerScreen(), + ), + ); + }, + heroTag: 'usb_ble_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: const Icon(Icons.bluetooth), + label: Text(context.l10n.connectionChoiceBluetoothLabel), + ), + if ((showTcp || showBle) && !_supportsHotPlug) + const SizedBox(width: 12), + if (!_supportsHotPlug) + FloatingActionButton.extended( + onPressed: isLoading ? null : _loadPorts, + heroTag: 'usb_refresh_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.usb), + label: Text(context.l10n.scanner_scan), + ), + ], + ), ), ); }, @@ -192,9 +220,18 @@ class _UsbScreenState extends State { children: [ Icon(Icons.circle, size: 12, color: statusColor), const SizedBox(width: 8), - Text( - statusText, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), ), ], ), diff --git a/lib/services/tcp_transport_service.dart b/lib/services/tcp_transport_service.dart new file mode 100644 index 0000000..0e6f7a1 --- /dev/null +++ b/lib/services/tcp_transport_service.dart @@ -0,0 +1,2 @@ +export 'tcp_transport_service_native.dart' + if (dart.library.js_interop) 'tcp_transport_service_web.dart'; diff --git a/lib/services/tcp_transport_service_native.dart b/lib/services/tcp_transport_service_native.dart new file mode 100644 index 0000000..d8a3ab3 --- /dev/null +++ b/lib/services/tcp_transport_service_native.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'app_debug_log_service.dart'; +import 'usb_serial_frame_codec.dart'; + +class TcpTransportService { + final StreamController _frameController = + StreamController.broadcast(); + final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder(); + + StreamSubscription? _socketSubscription; + Socket? _socket; + AppDebugLogService? _debugLogService; + TcpTransportStatus _status = TcpTransportStatus.disconnected; + String? _activeHost; + int? _activePort; + Future _pendingWrite = Future.value(); + int _connectGeneration = 0; + + TcpTransportStatus get status => _status; + Stream get frameStream => _frameController.stream; + bool get isConnected => _status == TcpTransportStatus.connected; + String? get activeEndpoint => _activeHost == null || _activePort == null + ? null + : '$_activeHost:$_activePort'; + + void setDebugLogService(AppDebugLogService? service) { + _debugLogService = service; + } + + Future connect({ + required String host, + required int port, + Duration timeout = const Duration(seconds: 10), + }) async { + if (_status == TcpTransportStatus.connected || + _status == TcpTransportStatus.connecting) { + throw StateError('TCP transport is already active'); + } + final trimmedHost = host.trim(); + if (trimmedHost.isEmpty) { + throw ArgumentError.value(host, 'host', 'Host cannot be empty'); + } + if (port < 1 || port > 65535) { + throw ArgumentError.value(port, 'port', 'Port must be in 1..65535'); + } + + _status = TcpTransportStatus.connecting; + final generation = ++_connectGeneration; + _frameDecoder.reset(); + + try { + final socket = await Socket.connect(trimmedHost, port, timeout: timeout); + if (generation != _connectGeneration || + _status != TcpTransportStatus.connecting) { + try { + await socket.close(); + } catch (_) {} + try { + socket.destroy(); + } catch (_) {} + return; + } + socket.setOption(SocketOption.tcpNoDelay, true); + _socket = socket; + _activeHost = trimmedHost; + _activePort = port; + _socketSubscription = socket.listen( + _handleSocketData, + onError: _handleSocketError, + onDone: _handleSocketDone, + ); + _status = TcpTransportStatus.connected; + _debugLogService?.info( + 'TCP transport opened endpoint=$activeEndpoint', + tag: 'TCP', + ); + } catch (error) { + await _cleanupFailedConnect(); + _status = TcpTransportStatus.disconnected; + rethrow; + } + } + + Future write(Uint8List data) async { + if (!isConnected || _socket == null) { + throw StateError('TCP transport is not connected'); + } + + final packet = wrapUsbSerialTxFrame(data); + _logFrameSummary('TCP TX frame', data); + + final writeTask = _pendingWrite.then((_) async { + final socket = _socket; + if (!isConnected || socket == null) { + throw StateError('TCP transport is not connected'); + } + socket.add(packet); + await socket.flush(); + }); + + _pendingWrite = writeTask.catchError((_) {}); + await writeTask; + } + + Future disconnect() async { + _connectGeneration += 1; + if (_status == TcpTransportStatus.disconnected) return; + + final endpoint = activeEndpoint; + _status = TcpTransportStatus.disconnecting; + _frameDecoder.reset(); + _activeHost = null; + _activePort = null; + + final subscription = _socketSubscription; + _socketSubscription = null; + await subscription?.cancel(); + + final socket = _socket; + _socket = null; + try { + await socket?.close(); + } catch (_) {} + try { + socket?.destroy(); + } catch (_) {} + + _status = TcpTransportStatus.disconnected; + _debugLogService?.info( + 'TCP transport closed endpoint=${endpoint ?? 'unknown'}', + tag: 'TCP', + ); + } + + void dispose() { + unawaited(disconnect().whenComplete(_closeFrameController)); + } + + Future _cleanupFailedConnect() async { + final subscription = _socketSubscription; + _socketSubscription = null; + await subscription?.cancel(); + final socket = _socket; + _socket = null; + try { + await socket?.close(); + } catch (_) {} + try { + socket?.destroy(); + } catch (_) {} + _activeHost = null; + _activePort = null; + _frameDecoder.reset(); + } + + void _handleSocketData(Uint8List bytes) { + for (final packet in _frameDecoder.ingest(bytes)) { + if (!packet.isRxFrame) { + _debugLogService?.info( + 'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}', + tag: 'TCP', + ); + continue; + } + _addFrame(packet.payload); + } + } + + void _handleSocketError(Object error, [StackTrace? stackTrace]) { + _addFrameError(error, stackTrace); + unawaited(disconnect()); + } + + void _handleSocketDone() { + unawaited(disconnect()); + } + + void _addFrame(Uint8List payload) { + if (_frameController.isClosed) return; + _frameController.add(payload); + } + + void _addFrameError(Object error, [StackTrace? stackTrace]) { + if (_frameController.isClosed) return; + _frameController.addError(error, stackTrace); + } + + void _logFrameSummary(String prefix, Uint8List payload) { + final code = payload.isNotEmpty ? payload.first : -1; + _debugLogService?.info( + '$prefix code=$code len=${payload.length}', + tag: 'TCP', + ); + } + + Future _closeFrameController() async { + if (_frameController.isClosed) return; + await _frameController.close(); + } +} + +enum TcpTransportStatus { disconnected, connecting, connected, disconnecting } diff --git a/lib/services/tcp_transport_service_web.dart b/lib/services/tcp_transport_service_web.dart new file mode 100644 index 0000000..c23179b --- /dev/null +++ b/lib/services/tcp_transport_service_web.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'app_debug_log_service.dart'; + +class TcpTransportService { + AppDebugLogService? _debugLogService; + + Stream get frameStream => const Stream.empty(); + bool get isConnected => false; + String? get activeEndpoint => null; + + void setDebugLogService(AppDebugLogService? service) { + _debugLogService = service; + } + + Future connect({ + required String host, + required int port, + Duration timeout = const Duration(seconds: 10), + }) async { + _debugLogService?.warn( + 'TCP transport requested on web for $host:$port', + tag: 'TCP', + ); + throw UnsupportedError('TCP transport is not supported on web.'); + } + + Future write(Uint8List data) async { + throw UnsupportedError('TCP transport is not supported on web.'); + } + + Future disconnect() async {} + + void dispose() {} +} diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart new file mode 100644 index 0000000..501046d --- /dev/null +++ b/test/screens/tcp_flow_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/screens/scanner_screen.dart'; +import 'package:meshcore_open/screens/tcp_screen.dart'; + +class _FakeMeshCoreConnector extends MeshCoreConnector { + _FakeMeshCoreConnector(); + + MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected; + MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth; + String? initialEndpoint; + int connectTcpCalls = 0; + String? lastHost; + int? lastPort; + + @override + MeshCoreConnectionState get state => initialState; + + @override + MeshCoreTransportType get activeTransport => initialTransport; + + @override + bool get isTcpTransportConnected => + initialState == MeshCoreConnectionState.connected && + initialTransport == MeshCoreTransportType.tcp; + + @override + String? get activeTcpEndpoint => initialEndpoint; + + @override + Future connectTcp({required String host, required int port}) async { + connectTcpCalls += 1; + lastHost = host; + lastPort = port; + } +} + +Widget _buildTestApp({ + required MeshCoreConnector connector, + required Widget child, + Locale? locale, +}) { + return ChangeNotifierProvider.value( + value: connector, + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + testWidgets('TcpScreen uses localized TCP copy', (tester) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + + expect(find.text(l10n.tcpScreenTitle), findsOneWidget); + expect(find.text(l10n.tcpHostLabel), findsOneWidget); + expect(find.text(l10n.tcpPortLabel), findsOneWidget); + expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget); + }); + + testWidgets('TcpScreen validation errors are localized', (tester) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + + await tester.enterText(find.byType(TextField).first, ''); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget); + expect(connector.connectTcpCalls, 0); + + await tester.enterText(find.byType(TextField).first, '192.168.1.50'); + await tester.enterText(find.byType(TextField).at(1), '99999'); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connector.connectTcpCalls, 0); + }); + + testWidgets('TCP Bluetooth action returns to existing scanner route', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const ScannerScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP')); + await tester.pumpAndSettle(); + expect(find.byType(TcpScreen), findsOneWidget); + + await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth')); + await tester.pumpAndSettle(); + + expect(find.byType(TcpScreen), findsNothing); + expect(find.byType(ScannerScreen), findsOneWidget); + final navigatorState = tester.state(find.byType(Navigator)); + expect(navigatorState.canPop(), isFalse); + + // ScannerScreen.dispose() schedules disconnect work that debounces notify. + // Drain that debounce timer before test teardown. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + + testWidgets('TcpScreen narrow width long status text does not overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = _FakeMeshCoreConnector() + ..initialState = MeshCoreConnectionState.connected + ..initialTransport = MeshCoreTransportType.tcp + ..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000'; + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + expect( + find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); +} diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart index 436d230..16e5a95 100644 --- a/test/screens/usb_flow_test.dart +++ b/test/screens/usb_flow_test.dart @@ -116,12 +116,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pump(); expect(connector.connectUsbCalls, 0); @@ -145,12 +140,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pump(); expect(connector.connectUsbCalls, 1); @@ -179,6 +169,68 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); + testWidgets('ScannerScreen narrow width keeps actions without overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const ScannerScreen()), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(ScannerScreen)); + final l10n = AppLocalizations.of(context); + expect(find.text(l10n.scanner_scan), findsOneWidget); + + if (PlatformInfo.supportsUsbSerial) { + expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget); + } + if (!PlatformInfo.isWeb) { + expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget); + } + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + + testWidgets('UsbScreen narrow width long status text does not overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = + _FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected) + ..fakeUsbTransportConnected = true + ..fakeActiveUsbPortDisplayLabel = + '/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label'; + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(UsbScreen)); + final l10n = AppLocalizations.of(context); + expect( + find.text( + l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!), + ), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + group('Error Handling', () { testWidgets('shows error SnackBar when listing ports fails', ( tester, @@ -212,12 +264,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pumpAndSettle(); expect(connectAttempted, isTrue); diff --git a/test/services/tcp_transport_service_native_test.dart b/test/services/tcp_transport_service_native_test.dart new file mode 100644 index 0000000..24c62ad --- /dev/null +++ b/test/services/tcp_transport_service_native_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/tcp_transport_service_native.dart'; +import 'package:meshcore_open/services/usb_serial_frame_codec.dart'; + +final class _DelayedConnectOverrides extends IOOverrides { + _DelayedConnectOverrides(this.delay); + + final Duration delay; + + @override + Future socketConnect( + host, + int port, { + sourceAddress, + int sourcePort = 0, + Duration? timeout, + }) async { + await Future.delayed(delay); + return super.socketConnect( + host, + port, + sourceAddress: sourceAddress, + sourcePort: sourcePort, + timeout: timeout, + ); + } +} + +void main() { + test('connect/disconnect updates TCP transport state', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final service = TcpTransportService(); + + try { + await service.connect( + host: InternetAddress.loopbackIPv4.address, + port: server.port, + ); + + expect(service.isConnected, isTrue); + expect( + service.activeEndpoint, + '${InternetAddress.loopbackIPv4.address}:${server.port}', + ); + + await service.disconnect(); + + expect(service.isConnected, isFalse); + expect(service.activeEndpoint, isNull); + } finally { + await service.disconnect(); + await server.close(); + } + }); + + test('disconnect is safe when already disconnected', () async { + final service = TcpTransportService(); + + await service.disconnect(); + await service.disconnect(); + + expect(service.isConnected, isFalse); + expect(service.activeEndpoint, isNull); + }); + + test('emits only RX frames from socket stream', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final acceptedSocket = Completer(); + final service = TcpTransportService(); + final receivedFrames = []; + + final serverSub = server.listen((socket) { + if (!acceptedSocket.isCompleted) { + acceptedSocket.complete(socket); + } else { + socket.destroy(); + } + }); + final frameSub = service.frameStream.listen(receivedFrames.add); + + try { + await service.connect( + host: InternetAddress.loopbackIPv4.address, + port: server.port, + ); + + final socket = await acceptedSocket.future.timeout( + const Duration(seconds: 2), + ); + + socket.add([usbSerialTxFrameStart, 0x01, 0x00, 0x11]); + socket.add([usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]); + await socket.flush(); + + await Future.delayed(const Duration(milliseconds: 20)); + + expect(receivedFrames, hasLength(1)); + expect(receivedFrames.single, orderedEquals([0x33, 0x44])); + } finally { + await service.disconnect(); + await frameSub.cancel(); + await serverSub.cancel(); + await server.close(); + } + }); + + test( + 'disconnect during in-flight connect keeps transport disconnected', + () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final service = TcpTransportService(); + final host = InternetAddress.loopbackIPv4.address; + + try { + await IOOverrides.runWithIOOverrides(() async { + final connectFuture = service.connect(host: host, port: server.port); + + await Future.delayed(const Duration(milliseconds: 10)); + await service.disconnect(); + await connectFuture; + + expect(service.isConnected, isFalse); + expect(service.status, TcpTransportStatus.disconnected); + expect(service.activeEndpoint, isNull); + }, _DelayedConnectOverrides(const Duration(milliseconds: 120))); + } finally { + await service.disconnect(); + await server.close(); + } + }, + ); +} From 929c1c3d286664d1d65301b1f9cb985a3edaafd1 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Mon, 9 Mar 2026 20:39:17 -0400 Subject: [PATCH 02/75] `fix(tcp): cancel pending connects on disconnect and propagate remote close` --- lib/connector/meshcore_connector_tcp.dart | 3 --- lib/services/tcp_transport_service_native.dart | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart index 01c2b92..92b98d7 100644 --- a/lib/connector/meshcore_connector_tcp.dart +++ b/lib/connector/meshcore_connector_tcp.dart @@ -22,9 +22,6 @@ class MeshCoreTcpManager { } Future disconnect() async { - if (!_service.isConnected && _service.activeEndpoint == null) { - return; - } _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); await _service.disconnect(); } diff --git a/lib/services/tcp_transport_service_native.dart b/lib/services/tcp_transport_service_native.dart index d8a3ab3..a6db99b 100644 --- a/lib/services/tcp_transport_service_native.dart +++ b/lib/services/tcp_transport_service_native.dart @@ -175,6 +175,11 @@ class TcpTransportService { } void _handleSocketDone() { + if (_status == TcpTransportStatus.disconnecting || + _status == TcpTransportStatus.disconnected) { + return; + } + _addFrameError(StateError('TCP socket closed by remote endpoint')); unawaited(disconnect()); } From 1913a5aa114ad57e2f0fcaf2af3b36f040f20d60 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 19:27:39 -0400 Subject: [PATCH 03/75] fix(tcp): guard connect cancellation race and align USB screen actions - add connectTcp cancellation guards after socket connect and connect delay so handshake does not proceed when transport/state changed - ignore late TCP connect errors after manual cancel or transport switch to avoid spurious second disconnect paths - keep TCP action hidden only on web and show Bluetooth action on USB screen across platforms for navigation consistency --- lib/connector/meshcore_connector.dart | 38 +++++++++++++++++++++++++++ lib/screens/usb_screen.dart | 5 +--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 135299e..aad2c71 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -995,9 +995,37 @@ class MeshCoreConnector extends ChangeNotifier { await _tcpFrameSubscription?.cancel(); _tcpFrameSubscription = null; await _tcpManager.connect(host: host, port: port); + final isTcpConnectCancelled = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting || + !_tcpManager.isConnected; + if (isTcpConnectCancelled) { + _appDebugLogService?.warn( + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + tag: 'TCP', + ); + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + return; + } notifyListeners(); await Future.delayed(const Duration(milliseconds: 200)); + final isTcpConnectCancelledAfterDelay = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting || + !_tcpManager.isConnected; + if (isTcpConnectCancelledAfterDelay) { + _appDebugLogService?.warn( + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + tag: 'TCP', + ); + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + return; + } _tcpFrameSubscription = _tcpManager.frameStream.listen( _handleFrame, onError: (error, stackTrace) { @@ -1031,6 +1059,16 @@ class MeshCoreConnector extends ChangeNotifier { await syncTime(); } catch (error) { _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); + final tcpConnectNoLongerActive = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting; + if (tcpConnectNoLongerActive) { + _appDebugLogService?.info( + 'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport', + tag: 'TCP', + ); + return; + } await disconnect(manual: false); rethrow; } diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 0cc9007..2f2713a 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -108,10 +108,7 @@ class _UsbScreenState extends State { bottomNavigationBar: Consumer( builder: (context, connector, child) { final isLoading = _isLoadingPorts; - final showBle = - PlatformInfo.isWeb || - PlatformInfo.isAndroid || - PlatformInfo.isIOS; + final showBle = true; final showTcp = !PlatformInfo.isWeb; return SafeArea( From 9db79e9d4034e6b07aa0fe5f826e2637a64fcf2b Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 20:06:05 -0400 Subject: [PATCH 04/75] test(tcp): harden cancel-race handling and add coverage - tighten late TCP connect error suppression to manual-cancel disconnecting/disconnected windows - keep TCP handshake failures surfaced outside explicit cancel flow - allow TcpScreen connect action when connector is scanning - add connector-level tests for late-error suppression classifier - add TcpScreen test covering connect from scanning state --- lib/connector/meshcore_connector.dart | 25 ++++++-- lib/screens/tcp_screen.dart | 6 +- ...hcore_connector_tcp_error_filter_test.dart | 64 +++++++++++++++++++ test/screens/tcp_flow_test.dart | 23 +++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 test/connector/meshcore_connector_tcp_error_filter_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index aad2c71..0de1a90 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1059,10 +1059,14 @@ class MeshCoreConnector extends ChangeNotifier { await syncTime(); } catch (error) { _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); - final tcpConnectNoLongerActive = - _activeTransport != MeshCoreTransportType.tcp || - _state != MeshCoreConnectionState.connecting; - if (tcpConnectNoLongerActive) { + final tcpConnectCancelledBeforeHandshake = + shouldIgnoreLateTcpConnectError( + manualDisconnect: _manualDisconnect, + state: _state, + activeTransport: _activeTransport, + tcpManagerConnected: _tcpManager.isConnected, + ); + if (tcpConnectCancelledBeforeHandshake) { _appDebugLogService?.info( 'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport', tag: 'TCP', @@ -1074,6 +1078,19 @@ class MeshCoreConnector extends ChangeNotifier { } } + @visibleForTesting + static bool shouldIgnoreLateTcpConnectError({ + required bool manualDisconnect, + required MeshCoreConnectionState state, + required MeshCoreTransportType activeTransport, + required bool tcpManagerConnected, + }) { + return manualDisconnect && + (state == MeshCoreConnectionState.disconnected || + state == MeshCoreConnectionState.disconnecting) && + (activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected); + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index f3702c3..55bec20 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -224,7 +224,11 @@ class _TcpScreenState extends State { } Future _connectTcp() async { - if (_connector.state != MeshCoreConnectionState.disconnected) return; + if (_connector.state == MeshCoreConnectionState.connecting || + _connector.state == MeshCoreConnectionState.connected || + _connector.state == MeshCoreConnectionState.disconnecting) { + return; + } final host = _hostController.text.trim(); final parsedPort = int.tryParse(_portController.text.trim()); diff --git a/test/connector/meshcore_connector_tcp_error_filter_test.dart b/test/connector/meshcore_connector_tcp_error_filter_test.dart new file mode 100644 index 0000000..ee6a382 --- /dev/null +++ b/test/connector/meshcore_connector_tcp_error_filter_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +void main() { + group('shouldIgnoreLateTcpConnectError', () { + test('returns true for manual cancel during disconnecting state', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isTrue); + }); + + test( + 'returns true for manual cancel after reaching disconnected state', + () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnected, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isTrue); + }, + ); + + test('returns false when not a manual disconnect', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: false, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isFalse); + }); + + test('returns false for connected state handshake failures', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.connected, + activeTransport: MeshCoreTransportType.tcp, + tcpManagerConnected: true, + ); + + expect(result, isFalse); + }); + + test('returns false when TCP is still active while disconnecting', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.tcp, + tcpManagerConnected: true, + ); + + expect(result, isFalse); + }); + }); +} diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 501046d..5c240f4 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -135,6 +135,29 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); + testWidgets('TcpScreen allows connect while connector is scanning', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector() + ..initialState = MeshCoreConnectionState.scanning; + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connector.connectTcpCalls, 1); + expect(connector.lastHost, '192.168.40.10'); + expect(connector.lastPort, 5000); + }); + testWidgets('TcpScreen narrow width long status text does not overflow', ( tester, ) async { From 2f770bbd532cb80044a8128682e5940fe639c632 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 21:38:35 -0400 Subject: [PATCH 05/75] fix(tcp): reset state on aborted pre-handshake connect --- lib/connector/meshcore_connector.dart | 42 +++++++++++++------ ...hcore_connector_tcp_error_filter_test.dart | 29 +++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0de1a90..c89e37f 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -992,6 +992,21 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connecting); try { + Future handleTcpConnectAbort({required String message}) async { + _appDebugLogService?.warn(message, tag: 'TCP'); + final shouldResetState = shouldResetStateAfterTcpConnectAbort( + state: _state, + activeTransport: _activeTransport, + ); + if (shouldResetState) { + await disconnect(manual: false); + return; + } + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + } + await _tcpFrameSubscription?.cancel(); _tcpFrameSubscription = null; await _tcpManager.connect(host: host, port: port); @@ -1000,13 +1015,10 @@ class MeshCoreConnector extends ChangeNotifier { _state != MeshCoreConnectionState.connecting || !_tcpManager.isConnected; if (isTcpConnectCancelled) { - _appDebugLogService?.warn( - 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', - tag: 'TCP', + await handleTcpConnectAbort( + message: + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', ); - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); - } return; } notifyListeners(); @@ -1017,13 +1029,10 @@ class MeshCoreConnector extends ChangeNotifier { _state != MeshCoreConnectionState.connecting || !_tcpManager.isConnected; if (isTcpConnectCancelledAfterDelay) { - _appDebugLogService?.warn( - 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', - tag: 'TCP', + await handleTcpConnectAbort( + message: + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', ); - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); - } return; } _tcpFrameSubscription = _tcpManager.frameStream.listen( @@ -1091,6 +1100,15 @@ class MeshCoreConnector extends ChangeNotifier { (activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected); } + @visibleForTesting + static bool shouldResetStateAfterTcpConnectAbort({ + required MeshCoreConnectionState state, + required MeshCoreTransportType activeTransport, + }) { + return state == MeshCoreConnectionState.connecting && + activeTransport == MeshCoreTransportType.tcp; + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { diff --git a/test/connector/meshcore_connector_tcp_error_filter_test.dart b/test/connector/meshcore_connector_tcp_error_filter_test.dart index ee6a382..c363b97 100644 --- a/test/connector/meshcore_connector_tcp_error_filter_test.dart +++ b/test/connector/meshcore_connector_tcp_error_filter_test.dart @@ -61,4 +61,33 @@ void main() { expect(result, isFalse); }); }); + + group('shouldResetStateAfterTcpConnectAbort', () { + test('returns true when TCP connect is still in connecting state', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.connecting, + activeTransport: MeshCoreTransportType.tcp, + ); + + expect(result, isTrue); + }); + + test('returns false when state is already disconnected', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.disconnected, + activeTransport: MeshCoreTransportType.tcp, + ); + + expect(result, isFalse); + }); + + test('returns false when transport switched away from TCP', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.connecting, + activeTransport: MeshCoreTransportType.bluetooth, + ); + + expect(result, isFalse); + }); + }); } From 1fba5312a234d19f7c617f04ec49937d3da97c82 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 12 Mar 2026 00:14:48 -0700 Subject: [PATCH 06/75] Refactor storage classes to include companion's public key (#277) * Refactor storage classes to include public key handling and improve data loading/saving logic * Remove redundant publicKeyHex handling from ContactDiscoveryStore and fix key reference in saveContacts method * Remove unused app_logger import from ContactDiscoveryStore * Add warning log for empty publicKeyHex in saveChannelMessages method * Add warning log for empty publicKeyHex in clearMessages method * Migrate legacy storage keys to scoped keys across multiple stores * Remove legacy unscoped keys during migration in storage classes * Update lib/storage/contact_store.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/connector/meshcore_connector.dart | 26 ++++++++++++-- lib/screens/channels_screen.dart | 2 ++ lib/storage/channel_message_store.dart | 46 ++++++++++++++++++++---- lib/storage/channel_order_store.dart | 41 +++++++++++++++++---- lib/storage/channel_settings_store.dart | 38 ++++++++++++++++++-- lib/storage/channel_store.dart | 38 +++++++++++++++++--- lib/storage/community_store.dart | 33 +++++++++++++++-- lib/storage/contact_discovery_store.dart | 6 ++-- lib/storage/contact_group_store.dart | 39 +++++++++++++++++--- lib/storage/contact_settings_store.dart | 38 ++++++++++++++++++-- lib/storage/contact_store.dart | 39 +++++++++++++++++--- lib/storage/message_store.dart | 44 ++++++++++++++++++++--- lib/storage/unread_store.dart | 39 +++++++++++++++++--- 13 files changed, 378 insertions(+), 51 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 89aeca0..2ea09ca 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -291,6 +291,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get isLoadingChannels => _isLoadingChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; + String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; double? get selfLongitude => _selfLongitude; @@ -663,6 +664,7 @@ class MeshCoreConnector extends ChangeNotifier { // Initialize notification service _notificationService.initialize(); _loadChannelOrder(); + _loadDiscoveredContactCache(); // Initialize retry service callbacks _retryService?.initialize( @@ -691,7 +693,7 @@ class MeshCoreConnector extends ChangeNotifier { } } - Future loadDiscoveredContactCache() async { + Future _loadDiscoveredContactCache() async { final cached = await _discoveryContactStore.loadContacts(); _discoveredContacts ..clear() @@ -1193,7 +1195,6 @@ class MeshCoreConnector extends ChangeNotifier { await _requestDeviceInfo(); _startBatteryPolling(); - unawaited(loadDiscoveredContactCache()); final gotSelfInfo = await _waitForSelfInfo( timeout: const Duration(seconds: 3), @@ -2489,6 +2490,27 @@ class MeshCoreConnector extends ChangeNotifier { selfName.isNotEmpty) { _usbManager.updateConnectedLabel(selfName); } + + //set all the stores' public key so they can load the correct data + _channelMessageStore.setPublicKeyHex = selfPublicKeyHex; + _messageStore.setPublicKeyHex = selfPublicKeyHex; + _channelOrderStore.setPublicKeyHex = selfPublicKeyHex; + _channelSettingsStore.setPublicKeyHex = selfPublicKeyHex; + _contactSettingsStore.setPublicKeyHex = selfPublicKeyHex; + _contactStore.setPublicKeyHex = selfPublicKeyHex; + _channelStore.setPublicKeyHex = selfPublicKeyHex; + _unreadStore.setPublicKeyHex = selfPublicKeyHex; + + // Now that we have self info, we can load all the persisted data for this node + _loadChannelOrder(); + loadContactCache(); + loadChannelSettings(); + loadCachedChannels(); + + // Load persisted channel messages + loadAllChannelMessages(); + loadUnreadState(); + _awaitingSelfInfo = false; _selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer = null; diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 582fee7..00820ed 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -106,7 +106,9 @@ class _ChannelsScreenState extends State @override Widget build(BuildContext context) { final connector = context.watch(); + final channelMessageStore = ChannelMessageStore(); + channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; // Auto-navigate back to scanner if disconnected if (!checkConnectionAndNavigate(connector)) { diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 1151514..9c9f7e8 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -1,5 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:meshcore_open/utils/app_logger.dart'; + import '../models/channel_message.dart'; import '../helpers/smaz.dart'; import 'prefs_manager.dart'; @@ -7,13 +9,25 @@ import 'prefs_manager.dart'; class ChannelMessageStore { static const String _keyPrefix = 'channel_messages_'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; + /// Save messages for a specific channel Future saveChannelMessages( int channelIndex, List messages, ) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save channel messages.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; // Convert messages to JSON final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); @@ -24,11 +38,31 @@ class ChannelMessageStore { /// Load messages for a specific channel Future> loadChannelMessages(int channelIndex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load channel messages.', + ); + return []; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; - final jsonString = prefs.getString(key); - if (jsonString == null) return []; + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $key', + ); + await prefs.setString(key, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { final jsonList = jsonDecode(jsonString) as List; @@ -42,14 +76,14 @@ class ChannelMessageStore { /// Clear messages for a specific channel Future clearChannelMessages(int channelIndex) async { final prefs = PrefsManager.instance; - final key = '$_keyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; await prefs.remove(key); } /// Clear all channel messages Future clearAllChannelMessages() async { final prefs = PrefsManager.instance; - final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix)); + final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)); for (var key in keys) { await prefs.remove(key); } diff --git a/lib/storage/channel_order_store.dart b/lib/storage/channel_order_store.dart index b9657c4..48a80f2 100644 --- a/lib/storage/channel_order_store.dart +++ b/lib/storage/channel_order_store.dart @@ -1,20 +1,49 @@ import 'dart:convert'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelOrderStore { - static const String _key = 'channel_order'; + static const String _keyPrefix = 'channel_order_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future saveChannelOrder(List order) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save channel order.'); + return; + } final prefs = PrefsManager.instance; - await prefs.setString(_key, jsonEncode(order)); + await prefs.setString(keyFor, jsonEncode(order)); } Future> loadChannelOrder() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load channel order.'); + return []; + } final prefs = PrefsManager.instance; - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return []; + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final decoded = jsonDecode(raw); + final decoded = jsonDecode(jsonString); if (decoded is List) { return decoded .map((value) => value is int ? value : int.tryParse('$value')) @@ -24,7 +53,7 @@ class ChannelOrderStore { } catch (_) { // fall through to legacy parse } - return raw + return jsonString .split(',') .map((value) => int.tryParse(value)) .whereType() diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index eee97aa..3b639cd 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -1,17 +1,49 @@ +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelSettingsStore { - static const String _smazKeyPrefix = 'channel_smaz_'; + static const String _keyPrefix = 'channel_smaz_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future loadSmazEnabled(int channelIndex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load channel settings.', + ); + return false; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; + final oldKey = '$_keyPrefix$channelIndex'; + bool? enabled = prefs.getBool(key); + if (enabled == null) { + // Attempt migration from legacy unscoped key on first load + enabled = prefs.getBool(oldKey); + prefs.remove(oldKey); + if (enabled != null) { + appLogger.info( + 'Migrating channel settings from legacy key $oldKey to scoped key $key', + ); + await prefs.setBool(key, enabled); + } + } return prefs.getBool(key) ?? false; } Future saveSmazEnabled(int channelIndex, bool enabled) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save channel settings.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$channelIndex'; + final key = '$keyFor$channelIndex'; await prefs.setBool(key, enabled); } } diff --git a/lib/storage/channel_store.dart b/lib/storage/channel_store.dart index eaa7a61..1bad7e3 100644 --- a/lib/storage/channel_store.dart +++ b/lib/storage/channel_store.dart @@ -2,18 +2,42 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/channel.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ChannelStore { - static const String _key = 'channels'; + static const String _keyPrefix = 'channels'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadChannels() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load channels.'); + return []; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); - if (jsonStr == null) return []; + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final jsonList = jsonDecode(jsonStr) as List; + final jsonList = jsonDecode(jsonString) as List; return jsonList .map((entry) => _fromJson(entry as Map)) .toList(); @@ -23,9 +47,13 @@ class ChannelStore { } Future saveChannels(List channels) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save channels.'); + return; + } final prefs = PrefsManager.instance; final jsonList = channels.map(_toJson).toList(); - await prefs.setString(_key, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } Map _toJson(Channel channel) { diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index a81cccd..c7198e7 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../models/community.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; /// Persists communities to local storage using SharedPreferences. @@ -9,12 +10,34 @@ import 'prefs_manager.dart'; /// Each community contains its secret K, so this data should /// be considered sensitive (though device encryption handles security). class CommunityStore { - static const String _communitiesKey = 'communities_v1'; + static const String _keyPrefix = 'communities_v1'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; /// Load all communities from storage Future> loadCommunities() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load communities.'); + return []; + } final prefs = PrefsManager.instance; - final jsonString = prefs.getString(_communitiesKey); + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } if (jsonString == null || jsonString.isEmpty) { return []; } @@ -32,9 +55,13 @@ class CommunityStore { /// Save all communities to storage Future saveCommunities(List communities) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save communities.'); + return; + } final prefs = PrefsManager.instance; final jsonList = communities.map((c) => c.toJson()).toList(); - await prefs.setString(_communitiesKey, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } /// Add a new community diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index 37bfbb4..ac47615 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -5,11 +5,11 @@ import '../models/discovery_contact.dart'; import 'prefs_manager.dart'; class ContactDiscoveryStore { - static const String _key = 'discovered_contacts'; + static const String _keyPrefix = 'discovered_contacts'; Future> loadContacts() async { final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); + final jsonStr = prefs.getString(_keyPrefix); if (jsonStr == null) return []; try { @@ -25,7 +25,7 @@ class ContactDiscoveryStore { Future saveContacts(List contacts) async { final prefs = PrefsManager.instance; final jsonList = contacts.map(_toJson).toList(); - await prefs.setString(_key, jsonEncode(jsonList)); + await prefs.setString(_keyPrefix, jsonEncode(jsonList)); } Map _toJson(DiscoveryContact contact) { diff --git a/lib/storage/contact_group_store.dart b/lib/storage/contact_group_store.dart index 907cc5c..c1a7702 100644 --- a/lib/storage/contact_group_store.dart +++ b/lib/storage/contact_group_store.dart @@ -1,17 +1,42 @@ import 'dart:convert'; import '../models/contact_group.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactGroupStore { - static const String _key = 'contact_groups'; + static const String _keyPrefix = 'contact_groups'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadGroups() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load contact groups.'); + return []; + } final prefs = PrefsManager.instance; - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return []; + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final decoded = jsonDecode(raw); + final decoded = jsonDecode(jsonString); if (decoded is List) { return decoded .whereType>() @@ -25,8 +50,12 @@ class ContactGroupStore { } Future saveGroups(List groups) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save contact groups.'); + return; + } final prefs = PrefsManager.instance; final encoded = jsonEncode(groups.map((group) => group.toJson()).toList()); - await prefs.setString(_key, encoded); + await prefs.setString(keyFor, encoded); } } diff --git a/lib/storage/contact_settings_store.dart b/lib/storage/contact_settings_store.dart index 5a7949d..94c6430 100644 --- a/lib/storage/contact_settings_store.dart +++ b/lib/storage/contact_settings_store.dart @@ -1,17 +1,49 @@ +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactSettingsStore { - static const String _smazKeyPrefix = 'contact_smaz_'; + static const String _keyPrefix = 'contact_smaz_'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future loadSmazEnabled(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot load contact settings.', + ); + return false; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; + final oldKey = '$_keyPrefix$contactKeyHex'; + bool? enabled = prefs.getBool(key); + if (enabled == null) { + // Attempt migration from legacy unscoped key on first load + enabled = prefs.getBool(oldKey); + prefs.remove(oldKey); + if (enabled != null) { + appLogger.info( + 'Migrating contact settings from legacy key $oldKey to scoped key $key', + ); + await prefs.setBool(key, enabled); + } + } return prefs.getBool(key) ?? false; } Future saveSmazEnabled(String contactKeyHex, bool enabled) async { + if (publicKeyHex.isEmpty) { + appLogger.warn( + 'Public key hex is not set. Cannot save contact settings.', + ); + return; + } final prefs = PrefsManager.instance; - final key = '$_smazKeyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; await prefs.setBool(key, enabled); } } diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 504ff16..8f9e84d 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -2,18 +2,43 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/contact.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class ContactStore { - static const String _key = 'contacts'; + static const String _keyPrefix = 'contacts'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; Future> loadContacts() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load contacts.'); + return []; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_key); - if (jsonStr == null) return []; + String? jsonString = prefs.getString(keyFor); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { - final jsonList = jsonDecode(jsonStr) as List; + final jsonList = jsonDecode(jsonString) as List; return jsonList .map((entry) => _fromJson(entry as Map)) .toList(); @@ -23,9 +48,13 @@ class ContactStore { } Future saveContacts(List contacts) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save contacts.'); + return; + } final prefs = PrefsManager.instance; final jsonList = contacts.map(_toJson).toList(); - await prefs.setString(_key, jsonEncode(jsonList)); + await prefs.setString(keyFor, jsonEncode(jsonList)); } Map _toJson(Contact contact) { diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 9526ef3..82caa78 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -2,26 +2,56 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; import '../helpers/smaz.dart'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; class MessageStore { static const String _keyPrefix = 'messages_'; + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length > 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; + Future saveMessages( String contactKeyHex, List messages, ) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save messages.'); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; final jsonList = messages.map(_messageToJson).toList(); await prefs.setString(key, jsonEncode(jsonList)); } Future> loadMessages(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load messages.'); + return []; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; - final jsonString = prefs.getString(key); - if (jsonString == null) return []; + final key = '$keyFor$contactKeyHex'; + final oldKey = '$_keyPrefix$contactKeyHex'; + String? jsonString = prefs.getString(key); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(oldKey); + prefs.remove(oldKey); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating messages from legacy key $oldKey to scoped key $key', + ); + await prefs.setString(key, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return []; + } try { final jsonList = jsonDecode(jsonString) as List; @@ -32,8 +62,12 @@ class MessageStore { } Future clearMessages(String contactKeyHex) async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot clear messages.'); + return; + } final prefs = PrefsManager.instance; - final key = '$_keyPrefix$contactKeyHex'; + final key = '$keyFor$contactKeyHex'; await prefs.remove(key); } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index 201d25e..c0cecee 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -1,11 +1,18 @@ import 'dart:async'; import 'dart:convert'; +import '../utils/app_logger.dart'; import 'prefs_manager.dart'; /// Storage for unread message tracking with debounced writes to reduce I/O. class UnreadStore { - static const String _contactUnreadCountKey = 'contact_unread_count'; + static const String _keyPrefix = 'contact_unread_count'; + + String publicKeyHex = ''; + set setPublicKeyHex(String value) => + publicKeyHex = value.length >= 10 ? value.substring(0, 10) : ''; + + String get keyFor => '$_keyPrefix$publicKeyHex'; // Debounce timers to batch rapid writes Timer? _contactUnreadSaveTimer; @@ -20,12 +27,30 @@ class UnreadStore { } Future> loadContactUnreadCount() async { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot load unread counts.'); + return {}; + } final prefs = PrefsManager.instance; - final jsonStr = prefs.getString(_contactUnreadCountKey); - if (jsonStr == null) return {}; + String? jsonString = prefs.getString(_keyPrefix); + if (jsonString == null || jsonString.isEmpty) { + // Attempt migration from legacy unscoped key on first load + final legacyJsonString = prefs.getString(_keyPrefix); + prefs.remove(_keyPrefix); + if (legacyJsonString != null && legacyJsonString.isNotEmpty) { + appLogger.info( + 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', + ); + await prefs.setString(keyFor, legacyJsonString); + jsonString = legacyJsonString; + } + } + if (jsonString == null || jsonString.isEmpty) { + return {}; + } try { - final json = jsonDecode(jsonStr) as Map; + final json = jsonDecode(jsonString) as Map; return json.map((key, value) => MapEntry(key, value as int)); } catch (_) { return {}; @@ -33,6 +58,10 @@ class UnreadStore { } void saveContactUnreadCount(Map counts) { + if (publicKeyHex.isEmpty) { + appLogger.warn('Public key hex is not set. Cannot save unread counts.'); + return; + } _pendingContactUnreadCount = counts; _contactUnreadSaveTimer?.cancel(); @@ -49,7 +78,7 @@ class UnreadStore { final prefs = PrefsManager.instance; final jsonStr = jsonEncode(_pendingContactUnreadCount); - await prefs.setString(_contactUnreadCountKey, jsonStr); + await prefs.setString(keyFor, jsonStr); _pendingContactUnreadCount = null; } From c81791cf1eea320af55fc4ffa3e443161b8d542d Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 12 Mar 2026 08:39:17 -0700 Subject: [PATCH 07/75] Migrate legacy storage keys to scoped keys in various store classes (#289) --- lib/storage/channel_message_store.dart | 13 ++++++++----- lib/storage/channel_settings_store.dart | 2 +- lib/storage/channel_store.dart | 4 ++++ lib/storage/community_store.dart | 3 +++ lib/storage/contact_group_store.dart | 3 +++ lib/storage/contact_store.dart | 3 +++ lib/storage/message_store.dart | 3 +++ lib/storage/unread_store.dart | 3 +++ 8 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 9c9f7e8..50d13f7 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -46,24 +46,27 @@ class ChannelMessageStore { } final prefs = PrefsManager.instance; final key = '$keyFor$channelIndex'; + final oldKey = '$_keyPrefix$channelIndex'; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(oldKey); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load - final legacyJsonString = prefs.getString(_keyPrefix); - prefs.remove(_keyPrefix); + final legacyJsonString = prefs.getString(oldKey); + prefs.remove(oldKey); if (legacyJsonString != null && legacyJsonString.isNotEmpty) { appLogger.info( - 'Migrating channel messages from legacy key $_keyPrefix to scoped key $key', + 'Migrating channel messages from legacy key $oldKey to scoped key $key', ); await prefs.setString(key, legacyJsonString); jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } - try { final jsonList = jsonDecode(jsonString) as List; return jsonList.map((json) => _messageFromJson(json)).toList(); diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index 3b639cd..3fb00eb 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -20,7 +20,7 @@ class ChannelSettingsStore { final prefs = PrefsManager.instance; final key = '$keyFor$channelIndex'; final oldKey = '$_keyPrefix$channelIndex'; - bool? enabled = prefs.getBool(key); + bool? enabled = prefs.getBool(oldKey); if (enabled == null) { // Attempt migration from legacy unscoped key on first load enabled = prefs.getBool(oldKey); diff --git a/lib/storage/channel_store.dart b/lib/storage/channel_store.dart index 1bad7e3..775398e 100644 --- a/lib/storage/channel_store.dart +++ b/lib/storage/channel_store.dart @@ -32,6 +32,10 @@ class ChannelStore { jsonString = legacyJsonString; } } + + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index c7198e7..6df859a 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -38,6 +38,9 @@ class CommunityStore { jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } diff --git a/lib/storage/contact_group_store.dart b/lib/storage/contact_group_store.dart index c1a7702..986bfdd 100644 --- a/lib/storage/contact_group_store.dart +++ b/lib/storage/contact_group_store.dart @@ -31,6 +31,9 @@ class ContactGroupStore { jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 8f9e84d..a4e2f0d 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -33,6 +33,9 @@ class ContactStore { jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 82caa78..9a39e3f 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -49,6 +49,9 @@ class MessageStore { jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return []; } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index c0cecee..d46fb41 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -45,6 +45,9 @@ class UnreadStore { jsonString = legacyJsonString; } } + if (jsonString == null || jsonString.isEmpty) { + jsonString = prefs.getString(keyFor); + } if (jsonString == null || jsonString.isEmpty) { return {}; } From 81758adc61d5efb84cefe5979185254038adf0e8 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 12 Mar 2026 23:08:46 -0700 Subject: [PATCH 08/75] Dev discovery (#291) * Refactor contact handling: replace DiscoveryContact with Contact, update related methods and settings * Enhance contact handling: include latitude, longitude, and last modified timestamp in contact updates; refactor path handling to accommodate discovered contacts across multiple screens * Enhance SNRIndicator: include discovered contacts in name resolution for repeaters * Refactor path handling: replace addReturnPath with buildPath to improve path construction logic and handle target contact types * Update lib/screens/map_screen.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add localization for "Show Discovery Contacts" in multiple languages and refactor location plausibility check in map screen * Enhance contact management: update discovered contacts' active status and improve contact handling with flags and raw packet data * Refactor ChannelsScreen: pass ChannelMessageStore to buildExpandedContent and ensure messages are cleared after channel creation * Update MapScreen: adjust label zoom threshold and refactor guessed marker building to include labels * Refactor ChannelsScreen: change channelMessageStore to a private getter and update its usage in buildExpandedContent calls * Enhance location plausibility check: add latitude and longitude bounds to ensure valid coordinates * Update lib/connector/meshcore_connector.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor MeshCoreConnector and related stores: update discovered contacts handling, migrate legacy keys, and set public key in community store * Refactor MeshCoreConnector and ChannelsScreen: update discovered contacts handling and set public key in community store; enhance location plausibility check in MapScreen * Update CMD_ADD_UPDATE_CONTACT frame format to include optional latitude and longitude fields --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/connector/meshcore_connector.dart | 58 +++++-- lib/connector/meshcore_protocol.dart | 53 +++++-- lib/l10n/app_bg.arb | 3 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_it.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_sk.dart | 3 + lib/l10n/app_localizations_sl.dart | 3 + lib/l10n/app_localizations_sv.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_sk.arb | 3 +- lib/l10n/app_sl.arb | 3 +- lib/l10n/app_sv.arb | 3 +- lib/l10n/app_uk.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/models/app_settings.dart | 8 + lib/models/contact.dart | 10 ++ lib/models/discovery_contact.dart | 105 ------------ lib/screens/ble_debug_log_screen.dart | 13 ++ lib/screens/channel_message_path_screen.dart | 17 +- lib/screens/channels_screen.dart | 42 +++-- lib/screens/community_qr_scanner_screen.dart | 5 + lib/screens/discovery_screen.dart | 15 +- lib/screens/map_screen.dart | 159 ++++++++++++++----- lib/screens/neighbors_screen.dart | 8 +- lib/screens/path_trace_map.dart | 55 +++++-- lib/services/app_settings_service.dart | 4 + lib/storage/channel_message_store.dart | 2 +- lib/storage/channel_order_store.dart | 2 +- lib/storage/channel_settings_store.dart | 2 +- lib/storage/channel_store.dart | 2 +- lib/storage/community_store.dart | 2 +- lib/storage/contact_discovery_store.dart | 38 ++++- lib/storage/contact_group_store.dart | 2 +- lib/storage/contact_store.dart | 8 + lib/storage/unread_store.dart | 2 +- lib/utils/contact_search.dart | 4 +- lib/widgets/snr_indicator.dart | 7 +- 56 files changed, 476 insertions(+), 241 deletions(-) delete mode 100644 lib/models/discovery_contact.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 2ea09ca..9ee6e92 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart' as crypto; -import 'package:meshcore_open/models/discovery_contact.dart'; import 'package:pointycastle/export.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; @@ -120,7 +119,7 @@ class MeshCoreConnector extends ChangeNotifier { final List _scanResults = []; final List _contacts = []; - final List _discoveredContacts = []; + final List _discoveredContacts = []; final List _channels = []; final Map> _conversations = {}; final Map> _channelMessages = {}; @@ -281,7 +280,7 @@ class MeshCoreConnector extends ChangeNotifier { ); } - List get discoveredContacts { + List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -664,7 +663,6 @@ class MeshCoreConnector extends ChangeNotifier { // Initialize notification service _notificationService.initialize(); _loadChannelOrder(); - _loadDiscoveredContactCache(); // Initialize retry service callbacks _retryService?.initialize( @@ -1904,7 +1902,11 @@ class MeshCoreConnector extends ChangeNotifier { Future removeContact(Contact contact) async { if (!isConnected) return; - _handleDiscovery(contact, Uint8List(0), noNotify: true); + _handleDiscovery( + contact, + contact.rawPacket ?? Uint8List(0), + noNotify: true, + ); await sendFrame(buildRemoveContactFrame(contact.publicKey)); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); @@ -1920,7 +1922,20 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - Future removeDiscoveredContact(DiscoveryContact contact) async { + Future updateKnownDiscovered() async { + if (!isConnected) return; + for (int i = 0; i < _discoveredContacts.length; i++) { + _discoveredContacts[i] = _discoveredContacts[i].copyWith( + isActive: _knownContactKeys.contains( + _discoveredContacts[i].publicKeyHex, + ), + ); + } + unawaited(_persistDiscoveredContacts()); + notifyListeners(); + } + + Future removeDiscoveredContact(Contact contact) async { if (!isConnected) return; _discoveredContacts.removeWhere( (c) => c.publicKeyHex == contact.publicKeyHex, @@ -1929,7 +1944,7 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } - Future importDiscoveredContact(DiscoveryContact contact) async { + Future importDiscoveredContact(Contact contact) async { if (!isConnected) return; await sendFrame( @@ -1938,11 +1953,23 @@ class MeshCoreConnector extends ChangeNotifier { contact.path, contact.pathLength, type: contact.type, - flags: 0, + flags: contact.flags, name: contact.name, + lat: contact.latitude, + lon: contact.longitude, + lastModified: contact.lastSeen, ), ); + // Update the discovered contact to mark it as active (imported) + final discoveredIndex = _discoveredContacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + if (discoveredIndex >= 0) { + _discoveredContacts[discoveredIndex] = + _discoveredContacts[discoveredIndex].copyWith(isActive: true); + } + _handleContactAdvert( Contact( publicKey: contact.publicKey, @@ -1953,6 +1980,7 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: DateTime.now(), + flags: contact.flags, ), ); notifyListeners(); @@ -1969,6 +1997,8 @@ class MeshCoreConnector extends ChangeNotifier { final existing = _contacts[existingIndex]; // Use copyWith to preserve pathOverride and pathOverrideBytes _contacts[existingIndex] = existing.copyWith( + pathOverride: null, + pathOverrideBytes: null, pathLength: -1, path: Uint8List(0), ); @@ -2324,6 +2354,7 @@ class MeshCoreConnector extends ChangeNotifier { debugPrint('Got END_OF_CONTACTS'); _isLoadingContacts = false; _preserveContactsOnRefresh = false; + unawaited(updateKnownDiscovered()); notifyListeners(); unawaited(_persistContacts()); if (PlatformInfo.isWeb && @@ -2510,6 +2541,7 @@ class MeshCoreConnector extends ChangeNotifier { // Load persisted channel messages loadAllChannelMessages(); loadUnreadState(); + _loadDiscoveredContactCache(); _awaitingSelfInfo = false; _selfInfoRetryTimer?.cancel(); @@ -4406,7 +4438,7 @@ class MeshCoreConnector extends ChangeNotifier { } importDiscoveredContact( - DiscoveryContact( + Contact( rawPacket: frame, publicKey: publicKey, name: name, @@ -4477,6 +4509,7 @@ class MeshCoreConnector extends ChangeNotifier { if (isNewContact) { final newContact = Contact( + rawPacket: rawPacket, publicKey: publicKey, name: name, type: type, @@ -4622,13 +4655,15 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: contact.lastSeen, + flags: 0, + isActive: false, ); notifyListeners(); unawaited(_persistDiscoveredContacts()); return; } - final disContact = DiscoveryContact( + final disContact = Contact( rawPacket: rawPacket, publicKey: contact.publicKey, name: contact.name, @@ -4638,6 +4673,9 @@ class MeshCoreConnector extends ChangeNotifier { latitude: contact.latitude, longitude: contact.longitude, lastSeen: contact.lastSeen, + lastMessageAt: contact.lastMessageAt, + isActive: false, + flags: 0, ); _discoveredContacts.add(disContact); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 3484d47..dc9a9f5 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -148,6 +148,19 @@ class BufferWriter { void writeHex(String hex) { writeBytes(hex2Uint8List(hex)); } + + void writeBytesPadded(Uint8List bytes, int totalLength) { + // Path data (64 bytes, zero-padded) + final bytesPadded = Uint8List(totalLength); + final len = bytes.length < totalLength ? bytes.length : totalLength; + if (bytes.isNotEmpty && len > 0) { + final copyLen = bytes.length < totalLength ? bytes.length : totalLength; + for (int i = 0; i < copyLen; i++) { + bytesPadded[i] = bytes[i]; + } + } + writeBytes(bytesPadded); + } } Uint8List hex2Uint8List(String hex) { @@ -676,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) { } // Build CMD_ADD_UPDATE_CONTACT frame to set custom path -// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4] +// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4] Uint8List buildUpdateContactPathFrame( Uint8List pubKey, - Uint8List customPath, + Uint8List path, int pathLen, { int type = 1, // ADV_TYPE_CHAT int flags = 0, String name = '', + double? lat, + double? lon, + DateTime? lastModified, }) { final writer = BufferWriter(); writer.writeByte(cmdAddUpdateContact); @@ -692,17 +708,7 @@ Uint8List buildUpdateContactPathFrame( writer.writeByte(flags); writer.writeByte(pathLen); - // Path data (64 bytes, zero-padded) - final pathPadded = Uint8List(maxPathSize); - if (customPath.isNotEmpty && pathLen > 0) { - final copyLen = customPath.length < maxPathSize - ? customPath.length - : maxPathSize; - for (int i = 0; i < copyLen; i++) { - pathPadded[i] = customPath[i]; - } - } - writer.writeBytes(pathPadded); + writer.writeBytesPadded(path, maxPathSize); // Name (32 bytes, null-padded) writer.writeCString(name, maxNameSize); @@ -711,6 +717,27 @@ Uint8List buildUpdateContactPathFrame( final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; writer.writeUInt32LE(timestamp); + if ((lat == null || lon == null) && lastModified != null) { + // If lat/lon not provided, write zeros + writer.writeInt32LE(0); + writer.writeInt32LE(0); + } else { + // Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6 + // Latitude + final latitude = lat ?? 0.0; + writer.writeInt32LE((latitude * 1e6).round()); + + // Longitude + final longitude = lon ?? 0.0; + writer.writeInt32LE((longitude * 1e6).round()); + } + + if (lastModified != null) { + // Last modified + final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000; + writer.writeUInt32LE(lastModifiedTimestamp); + } + return writer.toBytes(); } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 94d8997..a2723d1 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbStatus_notConnected": "Изберете USB устройство", "usbStatus_searching": "Търсене на USB устройства...", - "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." + "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.", + "map_showDiscoveryContacts": "Покажи контакти за откриване" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9ba0f51..2f51360 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1887,5 +1887,6 @@ "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", - "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." + "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.", + "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2605628..ab33cea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -807,6 +807,7 @@ "map_markers": "Markers", "map_showSharedMarkers": "Show shared markers", "map_showGuessedLocations": "Show guessed node locations", + "map_showDiscoveryContacts": "Show Discovery Contacts", "map_guessedLocation": "Guessed location", "map_lastSeenTime": "Last Seen Time", "map_sharedPin": "Shared pin", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9b791d3..98d7c04 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1887,5 +1887,6 @@ "usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_notConnected": "Seleccione un dispositivo USB", "usbConnectionFailed": "Error al conectar mediante USB: {error}", - "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." + "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.", + "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a7bedc9..fe437e6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Échec de la connexion USB : {error}", "usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_searching": "Recherche de périphériques USB...", - "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." + "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.", + "map_showDiscoveryContacts": "Afficher les contacts de découverte" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 423ff40..6368ab3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Errore nella connessione USB: {error}", "usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_connecting": "Connessione al dispositivo USB...", - "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." + "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.", + "map_showDiscoveryContacts": "Mostra Contatti di Discovery" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d3f86b..a2a25ec 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2788,6 +2788,12 @@ abstract class AppLocalizations { /// **'Show guessed node locations'** String get map_showGuessedLocations; + /// No description provided for @map_showDiscoveryContacts. + /// + /// In en, this message translates to: + /// **'Show Discovery Contacts'** + String get map_showDiscoveryContacts; + /// No description provided for @map_guessedLocation. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 356106e..ce9c061 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1531,6 +1531,9 @@ class AppLocalizationsBg extends AppLocalizations { String get map_showGuessedLocations => 'Покажете местоположенията на предположените възли.'; + @override + String get map_showDiscoveryContacts => 'Покажи контакти за откриване'; + @override String get map_guessedLocation => 'Предполагано местоположение'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6353f35..14cb58c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1531,6 +1531,9 @@ class AppLocalizationsDe extends AppLocalizations { String get map_showGuessedLocations => 'Zeige die vermuteten Knotenpositionen'; + @override + String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen'; + @override String get map_guessedLocation => 'Geschätzter Ort'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c20df7..9d9dab4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1506,6 +1506,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_showGuessedLocations => 'Show guessed node locations'; + @override + String get map_showDiscoveryContacts => 'Show Discovery Contacts'; + @override String get map_guessedLocation => 'Guessed location'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index eecbd48..98c0f37 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1529,6 +1529,9 @@ class AppLocalizationsEs extends AppLocalizations { String get map_showGuessedLocations => 'Mostrar las ubicaciones estimadas de los nodos.'; + @override + String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento'; + @override String get map_guessedLocation => 'Ubicación estimada'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5cabc86..697db20 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1536,6 +1536,9 @@ class AppLocalizationsFr extends AppLocalizations { String get map_showGuessedLocations => 'Afficher les emplacements des nœuds estimés'; + @override + String get map_showDiscoveryContacts => 'Afficher les contacts de découverte'; + @override String get map_guessedLocation => 'Lieu deviné'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d170540..3655cdd 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1528,6 +1528,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; + @override + String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery'; + @override String get map_guessedLocation => 'Località indovinata'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 323ba34..1acb14d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1521,6 +1521,9 @@ class AppLocalizationsNl extends AppLocalizations { String get map_showGuessedLocations => 'Toon de voorspelde locaties van de knopen'; + @override + String get map_showDiscoveryContacts => 'Ontdek contacten weergeven'; + @override String get map_guessedLocation => 'Geroerde locatie'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 9175c3e..5d8e335 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1530,6 +1530,9 @@ class AppLocalizationsPl extends AppLocalizations { String get map_showGuessedLocations => 'Wyświetl lokalizacje zgadanych węzłów'; + @override + String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania'; + @override String get map_guessedLocation => 'Wydana lokalizacja'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ff09213..a533fa5 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1530,6 +1530,9 @@ class AppLocalizationsPt extends AppLocalizations { String get map_showGuessedLocations => 'Mostrar as localizações dos nós estimados'; + @override + String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta'; + @override String get map_guessedLocation => 'Localização estimada'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 69a5891..7bf4418 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1532,6 +1532,9 @@ class AppLocalizationsRu extends AppLocalizations { String get map_showGuessedLocations => 'Отобразить предполагаемые места расположения узлов'; + @override + String get map_showDiscoveryContacts => 'Показать контакты Discovery'; + @override String get map_guessedLocation => 'Угаданное место'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d0e75b0..516fb6c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1524,6 +1524,9 @@ class AppLocalizationsSk extends AppLocalizations { String get map_showGuessedLocations => 'Zobraziť umiestnenia odhadnutých uzlov'; + @override + String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov'; + @override String get map_guessedLocation => 'Odhadnutá lokalita'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 21e3d9d..f6f0df8 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1517,6 +1517,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; + @override + String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov'; + @override String get map_guessedLocation => 'Predpostavljena lokacija'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 5951fae..9595dc0 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1514,6 +1514,9 @@ class AppLocalizationsSv extends AppLocalizations { String get map_showGuessedLocations => 'Visa upp de antagna nodernas placeringar'; + @override + String get map_showDiscoveryContacts => 'Visa Discovery-kontakter'; + @override String get map_guessedLocation => 'Gissad plats'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b8fd60a..2e2537b 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1529,6 +1529,9 @@ class AppLocalizationsUk extends AppLocalizations { String get map_showGuessedLocations => 'Показати місцезнаходження передбачених вузлів'; + @override + String get map_showDiscoveryContacts => 'Показати контакти Відкриття'; + @override String get map_guessedLocation => 'Визначено місцезнаходження'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 27e6c21..058dce1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1440,6 +1440,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_showGuessedLocations => '显示猜测的节点位置'; + @override + String get map_showDiscoveryContacts => '显示发现联系人'; + @override String get map_guessedLocation => '猜测的位置'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 94df130..0a29595 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_searching": "Zoeken naar USB-apparaten...", - "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." + "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.", + "map_showDiscoveryContacts": "Ontdek contacten weergeven" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d020e0e..43ab9dd 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1859,5 +1859,6 @@ "usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_notConnected": "Wybierz urządzenie USB", "usbConnectionFailed": "Błąd połączenia USB: {error}", - "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." + "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".", + "map_showDiscoveryContacts": "Pokaż kontakty odkrywania" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d52cb41..11aa84d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Selecione um dispositivo USB", "usbConnectionFailed": "Falha na conexão USB: {error}", "usbStatus_connecting": "Conectando ao dispositivo USB...", - "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." + "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.", + "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 92fd55e..af9c220 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1099,5 +1099,6 @@ "usbStatus_connecting": "Подключение к USB-устройству...", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbStatus_notConnected": "Выберите USB-устройство", - "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." + "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.", + "map_showDiscoveryContacts": "Показать контакты Discovery" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 141147c..e844f60 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1859,5 +1859,6 @@ "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_connecting": "Pripojenie k USB zariadeniu...", - "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." + "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.", + "map_showDiscoveryContacts": "Zobraziť kontakty objavov" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 12529d6..939aad6 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1859,5 +1859,6 @@ "usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_searching": "Iskanje USB naprav...", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", - "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." + "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.", + "map_showDiscoveryContacts": "Prikaži odkritja kontaktov" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index f7615df..9611f18 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Välj en USB-enhet", "usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbStatus_searching": "Söker efter USB-enheter...", - "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." + "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.", + "map_showDiscoveryContacts": "Visa Discovery-kontakter" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 7794098..389184c 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1859,5 +1859,6 @@ "usbStatus_notConnected": "Виберіть пристрій USB", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbStatus_connecting": "Підключення до USB-пристрою...", - "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." + "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.", + "map_showDiscoveryContacts": "Показати контакти Відкриття" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dfc8e64..8a52983 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1864,5 +1864,6 @@ "usbStatus_connecting": "连接USB设备...", "usbStatus_notConnected": "选择一个 USB 设备", "usbConnectionFailed": "USB 连接失败:{error}", - "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" + "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。", + "map_showDiscoveryContacts": "显示发现联系人" } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index abcc729..c89ac27 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -39,6 +39,7 @@ class AppSettings { final Map batteryChemistryByRepeaterId; final UnitSystem unitSystem; final Set mutedChannels; + final bool mapShowDiscoveryContacts; AppSettings({ this.clearPathOnMaxRetry = false, @@ -66,6 +67,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, this.unitSystem = UnitSystem.metric, Set? mutedChannels, + this.mapShowDiscoveryContacts = true, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, mutedChannels = mutedChannels ?? {}; @@ -97,6 +99,7 @@ class AppSettings { 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'unit_system': unitSystem.value, 'muted_channels': mutedChannels.toList(), + 'map_show_discovery_contacts': mapShowDiscoveryContacts, }; } @@ -152,6 +155,8 @@ class AppSettings { ?.map((e) => e.toString()) .toSet()) ?? {}, + mapShowDiscoveryContacts: + json['map_show_discovery_contacts'] as bool? ?? true, ); } @@ -181,6 +186,7 @@ class AppSettings { Map? batteryChemistryByRepeaterId, UnitSystem? unitSystem, Set? mutedChannels, + bool? mapShowDiscoveryContacts, }) { return AppSettings( clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, @@ -217,6 +223,8 @@ class AppSettings { batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, unitSystem: unitSystem ?? this.unitSystem, mutedChannels: mutedChannels ?? this.mutedChannels, + mapShowDiscoveryContacts: + mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, ); } } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index b4acff7..cab58cb 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -17,6 +17,8 @@ class Contact { final double? longitude; final DateTime lastSeen; final DateTime lastMessageAt; + final bool isActive; + final Uint8List? rawPacket; Contact({ required this.publicKey, @@ -31,6 +33,8 @@ class Contact { this.longitude, required this.lastSeen, DateTime? lastMessageAt, + this.isActive = true, + this.rawPacket, }) : lastMessageAt = lastMessageAt ?? lastSeen; String get publicKeyHex => pubKeyToHex(publicKey); @@ -78,6 +82,8 @@ class Contact { double? longitude, DateTime? lastSeen, DateTime? lastMessageAt, + bool? isActive, + Uint8List? rawPacket, }) { return Contact( publicKey: publicKey ?? this.publicKey, @@ -96,6 +102,8 @@ class Contact { longitude: longitude ?? this.longitude, lastSeen: lastSeen ?? this.lastSeen, lastMessageAt: lastMessageAt ?? this.lastMessageAt, + isActive: isActive ?? this.isActive, + rawPacket: rawPacket ?? this.rawPacket, ); } @@ -204,6 +212,8 @@ class Contact { latitude: lat, longitude: lon, lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000), + isActive: true, + rawPacket: null, ); } catch (e) { appLogger.error('Failed to parse contact frame: $e'); diff --git a/lib/models/discovery_contact.dart b/lib/models/discovery_contact.dart deleted file mode 100644 index f6c6a52..0000000 --- a/lib/models/discovery_contact.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'dart:typed_data'; -import '../connector/meshcore_protocol.dart'; - -class DiscoveryContact { - final Uint8List rawPacket; - final Uint8List publicKey; - final String name; - final int type; - final int pathLength; // -1 = flood, 0+ = direct hops (from device) - final Uint8List path; // Path bytes from device - final double? latitude; - final double? longitude; - final DateTime lastSeen; - - DiscoveryContact({ - required this.rawPacket, - required this.publicKey, - required this.name, - required this.type, - required this.pathLength, - required this.path, - this.latitude, - this.longitude, - required this.lastSeen, - }); - - String get publicKeyHex => pubKeyToHex(publicKey); - - String get typeLabel { - switch (type) { - case advTypeChat: - return 'Chat'; - case advTypeRepeater: - return 'Repeater'; - case advTypeRoom: - return 'Room'; - case advTypeSensor: - return 'Sensor'; - default: - return 'Unknown'; - } - } - - String get pathLabel { - if (pathLength < 0) return 'Flood'; - if (pathLength == 0) return 'Direct'; - return '$pathLength hops'; - } - - bool get hasLocation => latitude != null && longitude != null; - - DiscoveryContact copyWith({ - Uint8List? rawPacket, - Uint8List? publicKey, - String? name, - int? type, - int? pathLength, - Uint8List? path, - double? latitude, - double? longitude, - DateTime? lastSeen, - }) { - return DiscoveryContact( - rawPacket: rawPacket ?? this.rawPacket, - publicKey: publicKey ?? this.publicKey, - name: name ?? this.name, - type: type ?? this.type, - pathLength: pathLength ?? this.pathLength, - path: path ?? this.path, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - lastSeen: lastSeen ?? this.lastSeen, - ); - } - - String get pathIdList { - final pathBytes = path; - if (pathBytes.isEmpty) return ''; - final parts = []; - final groupSize = pathHashSize; - for (int i = 0; i < pathBytes.length; i += groupSize) { - final end = (i + groupSize) <= pathBytes.length - ? (i + groupSize) - : pathBytes.length; - final chunk = pathBytes.sublist(i, end); - parts.add( - chunk - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(), - ); - } - return parts.join(','); - } - - String get shortPubKeyHex { - return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; - } - - @override - bool operator ==(Object other) => - other is DiscoveryContact && publicKeyHex == other.publicKeyHex; - - @override - int get hashCode => publicKeyHex.hashCode; -} diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 88f734b..a90f9f0 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State { : Icons.download, size: 18, ), + onLongPress: () async { + await Clipboard.setData( + ClipboardData( + text: entry.payload + .map( + (b) => b + .toRadixString(16) + .padLeft(2, '0'), + ) + .join(''), + ), + ); + }, ); } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 44dfe79..c2c57f0 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - - final hops = _buildPathHops(primaryPath, connector.contacts, l10n); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + final hops = _buildPathHops(primaryPath, contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( primaryPath.length, @@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final hops = _buildPathHops( - selectedPath, - connector.contacts, - context.l10n, - ); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + final hops = _buildPathHops(selectedPath, contacts, context.l10n); final points = []; diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 00820ed..b56b563 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -51,6 +51,8 @@ class _ChannelsScreenState extends State // Cache of PSK hex -> Community for quick lookup final Map _pskToCommunity = {}; + ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); + @override void initState() { super.initState(); @@ -61,6 +63,8 @@ class _ChannelsScreenState extends State } Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; final communities = await _communityStore.loadCommunities(); if (mounted) { setState(() { @@ -714,6 +718,8 @@ class _ChannelsScreenState extends State bool isRegularHashtag = true; Community? selectedCommunity; + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + showDialog( context: context, builder: (dialogContext) => StatefulBuilder( @@ -765,7 +771,9 @@ class _ChannelsScreenState extends State ); } - Widget? buildExpandedContent() { + Widget? buildExpandedContent( + ChannelMessageStore channelMessageStore, + ) { switch (selectedOption) { case 0: // Create Private Channel return Column( @@ -790,7 +798,7 @@ class _ChannelsScreenState extends State children: [ Expanded( child: FilledButton( - onPressed: () { + onPressed: () async { final name = nameController.text.trim(); if (name.isEmpty) { ScaffoldMessenger.of( @@ -812,7 +820,14 @@ class _ChannelsScreenState extends State psk[i] = random.nextInt(256); } Navigator.pop(dialogContext); - connector.setChannel(nextIndex, name, psk); + await connector.setChannel( + nextIndex, + name, + psk, + ); + await channelMessageStore.clearChannelMessages( + nextIndex, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1331,7 +1346,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_createPrivateChannelDesc, ), - if (selectedOption == 0) buildExpandedContent()!, + if (selectedOption == 0) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 1, @@ -1340,7 +1356,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinPrivateChannelDesc, ), - if (selectedOption == 1) buildExpandedContent()!, + if (selectedOption == 1) + buildExpandedContent(_channelMessageStore)!, if (!hasPublicChannel) ...[ const Divider(height: 1), buildOptionTile( @@ -1350,7 +1367,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinPublicChannelDesc, ), - if (selectedOption == 2) buildExpandedContent()!, + if (selectedOption == 2) + buildExpandedContent(_channelMessageStore)!, ], const Divider(height: 1), buildOptionTile( @@ -1360,7 +1378,8 @@ class _ChannelsScreenState extends State subtitle: dialogContext.l10n.channels_joinHashtagChannelDesc, ), - if (selectedOption == 3) buildExpandedContent()!, + if (selectedOption == 3) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 4, @@ -1368,7 +1387,8 @@ class _ChannelsScreenState extends State title: dialogContext.l10n.community_scanQr, subtitle: dialogContext.l10n.community_join, ), - if (selectedOption == 4) buildExpandedContent()!, + if (selectedOption == 4) + buildExpandedContent(_channelMessageStore)!, const Divider(height: 1), buildOptionTile( optionIndex: 5, @@ -1376,7 +1396,8 @@ class _ChannelsScreenState extends State title: dialogContext.l10n.community_create, subtitle: dialogContext.l10n.community_createDesc, ), - if (selectedOption == 5) buildExpandedContent()!, + if (selectedOption == 5) + buildExpandedContent(_channelMessageStore)!, ], ), ), @@ -1526,7 +1547,7 @@ class _ChannelsScreenState extends State try { await connector.deleteChannel(channel.index); - channelMessageStore.clearChannelMessages(channel.index); + await channelMessageStore.clearChannelMessages(channel.index); if (!context.mounted) return; @@ -1751,6 +1772,7 @@ class _ChannelsScreenState extends State } final channelCount = communityChannels.length; + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; showDialog( context: context, diff --git a/lib/screens/community_qr_scanner_screen.dart b/lib/screens/community_qr_scanner_screen.dart index 9f8602d..6852dfa 100644 --- a/lib/screens/community_qr_scanner_screen.dart +++ b/lib/screens/community_qr_scanner_screen.dart @@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State { _isProcessing = true; }); + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + try { // Parse the community data final community = Community.fromQrData(const Uuid().v4(), data); @@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State { bool addPublicChannel, ) async { // Save community to local storage + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; await _communityStore.addCommunity(community); // Optionally add the community public channel to the device diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index f122654..7f065aa 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; -import '../models/discovery_contact.dart'; +import '../models/contact.dart'; import '../utils/contact_search.dart'; import '../widgets/app_bar.dart'; import '../widgets/list_filter_widget.dart'; @@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State { } Future _showContactContextMenu( - DiscoveryContact contact, + Contact contact, MeshCoreConnector connector, ) async { final action = await showModalBottomSheet( @@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State { connector.importDiscoveredContact(contact); break; case 'copy_contact': - final hexString = pubKeyToHex(contact.rawPacket); + if (contact.rawPacket == null) return; + final hexString = pubKeyToHex(contact.rawPacket!); Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State { } Widget _buildFilters( - List filteredAndSorted, + List filteredAndSorted, MeshCoreConnector connector, ) { String hintText = ""; @@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State { ); } - List _filterAndSortContacts( - List contacts, + List _filterAndSortContacts( + List contacts, MeshCoreConnector connector, ) { var filtered = contacts.where((contact) { @@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State { return filtered; } - bool _matchesTypeFilter(DiscoveryContact contact) { + bool _matchesTypeFilter(Contact contact) { switch (typeFilter) { case ContactTypeFilter.all: return true; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3d94701..7ffec56 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget { } class _MapScreenState extends State { - static const double _labelZoomThreshold = 8.5; + // Zoom level at which node labels start to appear + static const double _labelZoomThreshold = 12.0; final MapController _mapController = MapController(); final MapMarkerService _markerService = MapMarkerService(); @@ -91,6 +93,15 @@ class _MapScreenState extends State { }); } + bool _checkLocationPlausibility(double lat, double lon) { + const double epsilon = 1e-6; + return (lat.abs() > epsilon || lon.abs() > epsilon) && + lat >= -90.0 && + lat <= 90.0 && + lon >= -180.0 && + lon <= 180.0; + } + double _standardDeviation(List values) { if (values.length <= 1) { return 0.0; @@ -126,7 +137,15 @@ class _MapScreenState extends State { builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final settings = settingsService.settings; - final contacts = connector.contacts; + final allContacts = [ + ...connector.contacts, + ...connector.discoveredContacts.where((c) => !c.isActive), + ]; + + final contacts = settings.mapShowDiscoveryContacts + ? allContacts + : allContacts.where((c) => c.isActive).toList(); + final highlightPosition = widget.highlightPosition; final sharedMarkers = settings.mapShowMarkers ? _collectSharedMarkers(connector) @@ -159,14 +178,21 @@ class _MapScreenState extends State { : filteredByTime; // Filter by location - final contactsWithLocation = filteredByKeyPrefix - .where((c) => c.hasLocation) - .toList(); + final contactsWithLocation = filteredByKeyPrefix.where((c) { + if (!c.hasLocation) { + return false; + } + return _checkLocationPlausibility(c.latitude!, c.longitude!); + }).toList(); // All contacts with a known location — used as anchors regardless of // time/key-prefix filters so that repeaters are always available. - final allContactsWithLocation = contacts - .where((c) => c.hasLocation) + final allContactsWithLocation = allContacts + .where( + (c) => + c.hasLocation && + _checkLocationPlausibility(c.latitude!, c.longitude!), + ) .toList(); // Compute guessed locations with caching @@ -468,7 +494,10 @@ class _MapScreenState extends State { ), ), if (!_isBuildingPathTrace) - ...guessedLocations.map(_buildGuessedMarker), + ..._buildGuessedMarker( + guessedLocations, + showLabels: _showNodeLabels, + ), ..._buildMarkers( contactsWithLocation, settings, @@ -630,6 +659,13 @@ class _MapScreenState extends State { anchors[0].latitude + offsetDeg * cos(angle), anchors[0].longitude + offsetDeg * sin(angle), ); + + if (!_checkLocationPlausibility( + position.latitude, + position.longitude, + )) { + continue; // discard implausible guesses near (0, 0) + } } else { double lat = 0, lon = 0; for (final a in anchors) { @@ -637,6 +673,12 @@ class _MapScreenState extends State { lon += a.longitude; } position = LatLng(lat / anchors.length, lon / anchors.length); + if (!_checkLocationPlausibility( + position.latitude, + position.longitude, + )) { + continue; // discard implausible guesses near (0, 0 + } } result.add( _GuessedLocation( @@ -710,40 +752,61 @@ class _MapScreenState extends State { .toList(); } - Marker _buildGuessedMarker(_GuessedLocation guess) { - final color = _getNodeColor(guess.contact.type); - return Marker( - point: guess.position, - width: 35, - height: 35, - child: GestureDetector( - onTap: () => _showNodeInfo( - context, - guess.contact, - guessedPosition: guess.position, - ), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + List _buildGuessedMarker( + List<_GuessedLocation> guessed, { + required bool showLabels, + }) { + final markers = []; + + for (final guess in guessed) { + final color = _getNodeColor(guess.contact.type); + final marker = Marker( + point: guess.position, + width: 35, + height: 35, + child: GestureDetector( + onTap: () => _showNodeInfo( + context, + guess.contact, + guessedPosition: guess.position, + ), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: color.withValues( + alpha: guess.highConfidence ? 0.55 : 0.30, ), - ], - ), - child: const Icon( - Icons.not_listed_location, - color: Colors.white, - size: 20, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.not_listed_location, + color: Colors.white, + size: 20, + ), ), ), - ), - ); + ); + + markers.add(marker); + + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: guess.position, + label: guess.contact.name, + ), + ); + } + } + return markers; } List _buildMarkers( @@ -1203,6 +1266,7 @@ class _MapScreenState extends State { Contact contact, { LatLng? guessedPosition, }) { + final connector = context.read(); showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -1248,6 +1312,9 @@ class _MapScreenState extends State { advTypeChat) // Only show chat button for chat nodes TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); Navigator.push( context, @@ -1261,6 +1328,9 @@ class _MapScreenState extends State { if (contact.type == advTypeRepeater) TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); _showRepeaterLogin(context, contact); }, @@ -1269,6 +1339,9 @@ class _MapScreenState extends State { if (contact.type == advTypeRoom) TextButton( onPressed: () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } Navigator.pop(dialogContext); _showRoomLogin(context, contact); }, @@ -1745,6 +1818,14 @@ class _MapScreenState extends State { }, contentPadding: EdgeInsets.zero, ), + CheckboxListTile( + title: Text(context.l10n.map_showDiscoveryContacts), + value: settings.mapShowDiscoveryContacts, + onChanged: (value) { + service.setMapShowDiscoveryContacts(value ?? true); + }, + contentPadding: EdgeInsets.zero, + ), const SizedBox(height: 16), Text( context.l10n.map_keyPrefix, diff --git a/lib/screens/neighbors_screen.dart b/lib/screens/neighbors_screen.dart index 3dee339..5cb8e45 100644 --- a/lib/screens/neighbors_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -124,12 +124,14 @@ class _NeighborsScreenState extends State { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; try { final neighborCount = buffer.readUInt16LE(); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); - connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( - repeater, - ) { + contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) { for (var neighborData in parsedNeighbors) { final publicKey = neighborData['publicKey']; if (listEquals( diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c6d800e..ceb60a6 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State { super.dispose(); } - Uint8List addReturnPath(Uint8List pathBytes) { - Uint8List? traceBytes; - 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]; + Uint8List buildPath(Uint8List pathBytes) { + Uint8List traceBytes; + + if (pathBytes.isEmpty) { + traceBytes = Uint8List(1); + traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0; + return traceBytes; + } + + if (widget.targetContact?.type == advTypeRepeater || + widget.targetContact?.type == advTypeRoom) { + final len = (pathBytes.length + pathBytes.length + 1); + traceBytes = Uint8List(len); + traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 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 ? Uint8List(0) : 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; @@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State { : widget.path; if (widget.flipPathRound) { - path = addReturnPath(pathTmp); + path = buildPath(pathTmp); } else { path = pathTmp; } + appLogger.info( + 'Initiating path trace with path: ${_formatPathPrefixes(path)}', + tag: 'PathTraceMapScreen', + ); + final connector = Provider.of(context, listen: false); final frame = buildTraceReq( DateTime.now().millisecondsSinceEpoch ~/ 1000, @@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State { .toList(); Map pathContacts = {}; - - connector.contacts.where((c) => c.type != advTypeChat).forEach(( - repeater, - ) { + final contacts = [ + ...connector.contacts, + ...connector.discoveredContacts, + ]; + contacts.where((c) => c.type != advTypeChat).forEach((repeater) { for (var repeaterData in pathData) { if (listEquals( repeater.publicKey.sublist(0, 1), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index c74fa40..a52e364 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier { appLogger.setEnabled(value); } + Future setMapShowDiscoveryContacts(bool value) async { + await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value)); + } + Future setBatteryChemistryForDevice( String deviceId, String chemistry, diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 50d13f7..7bf44bd 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -48,7 +48,7 @@ class ChannelMessageStore { final key = '$keyFor$channelIndex'; final oldKey = '$_keyPrefix$channelIndex'; - String? jsonString = prefs.getString(oldKey); + String? jsonString = prefs.getString(key); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(oldKey); diff --git a/lib/storage/channel_order_store.dart b/lib/storage/channel_order_store.dart index 48a80f2..88d3f7a 100644 --- a/lib/storage/channel_order_store.dart +++ b/lib/storage/channel_order_store.dart @@ -26,7 +26,7 @@ class ChannelOrderStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/channel_settings_store.dart b/lib/storage/channel_settings_store.dart index 3fb00eb..276826d 100644 --- a/lib/storage/channel_settings_store.dart +++ b/lib/storage/channel_settings_store.dart @@ -32,7 +32,7 @@ class ChannelSettingsStore { await prefs.setBool(key, enabled); } } - return prefs.getBool(key) ?? false; + return enabled ?? false; } Future saveSmazEnabled(int channelIndex, bool enabled) async { diff --git a/lib/storage/channel_store.dart b/lib/storage/channel_store.dart index 775398e..4f40482 100644 --- a/lib/storage/channel_store.dart +++ b/lib/storage/channel_store.dart @@ -19,7 +19,7 @@ class ChannelStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index 6df859a..c69d0b8 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -25,7 +25,7 @@ class CommunityStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index ac47615..89ca027 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -1,13 +1,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import '../models/discovery_contact.dart'; +import '../models/contact.dart'; import 'prefs_manager.dart'; class ContactDiscoveryStore { static const String _keyPrefix = 'discovered_contacts'; - Future> loadContacts() async { + Future> loadContacts() async { final prefs = PrefsManager.instance; final jsonStr = prefs.getString(_keyPrefix); if (jsonStr == null) return []; @@ -22,40 +22,62 @@ class ContactDiscoveryStore { } } - Future saveContacts(List contacts) async { + Future saveContacts(List contacts) async { final prefs = PrefsManager.instance; final jsonList = contacts.map(_toJson).toList(); await prefs.setString(_keyPrefix, jsonEncode(jsonList)); } - Map _toJson(DiscoveryContact contact) { + Map _toJson(Contact contact) { return { - 'rawPacket': base64Encode(contact.rawPacket), 'publicKey': base64Encode(contact.publicKey), 'name': contact.name, 'type': contact.type, + 'flags': contact.flags, 'pathLength': contact.pathLength, 'path': base64Encode(contact.path), + 'pathOverride': contact.pathOverride, + 'pathOverrideBytes': contact.pathOverrideBytes != null + ? base64Encode(contact.pathOverrideBytes!) + : null, 'latitude': contact.latitude, 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, + 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, + 'rawPacket': contact.rawPacket != null + ? base64Encode(contact.rawPacket!) + : null, }; } - DiscoveryContact _fromJson(Map json) { + Contact _fromJson(Map json) { final lastSeenMs = json['lastSeen'] as int? ?? 0; - return DiscoveryContact( - rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), + final lastMessageMs = json['lastMessageAt'] as int?; + return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', type: json['type'] as int? ?? 0, + flags: json['flags'] as int? ?? 0, pathLength: json['pathLength'] as int? ?? -1, path: json['path'] != null ? Uint8List.fromList(base64Decode(json['path'] as String)) : Uint8List(0), + pathOverride: json['pathOverride'] as int?, + pathOverrideBytes: json['pathOverrideBytes'] != null + ? Uint8List.fromList( + base64Decode(json['pathOverrideBytes'] as String), + ) + : null, latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), + lastMessageAt: DateTime.fromMillisecondsSinceEpoch( + lastMessageMs ?? lastSeenMs, + ), + isActive: false, + rawPacket: json['rawPacket'] != null + ? Uint8List.fromList(base64Decode(json['rawPacket'] as String)) + : null, ); } } diff --git a/lib/storage/contact_group_store.dart b/lib/storage/contact_group_store.dart index 986bfdd..ce6a0c6 100644 --- a/lib/storage/contact_group_store.dart +++ b/lib/storage/contact_group_store.dart @@ -18,7 +18,7 @@ class ContactGroupStore { return []; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index a4e2f0d..0e2e3ad 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -76,6 +76,10 @@ class ContactStore { 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, + 'isActive': contact.isActive, + 'rawPacket': contact.rawPacket != null + ? base64Encode(contact.rawPacket!) + : null, }; } @@ -103,6 +107,10 @@ class ContactStore { lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageMs ?? lastSeenMs, ), + isActive: json['isActive'] as bool? ?? true, + rawPacket: json['rawPacket'] != null + ? Uint8List.fromList(base64Decode(json['rawPacket'] as String)) + : null, ); } } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index d46fb41..3b615b1 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -32,7 +32,7 @@ class UnreadStore { return {}; } final prefs = PrefsManager.instance; - String? jsonString = prefs.getString(_keyPrefix); + String? jsonString = prefs.getString(keyFor); if (jsonString == null || jsonString.isEmpty) { // Attempt migration from legacy unscoped key on first load final legacyJsonString = prefs.getString(_keyPrefix); diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index beec880..1f05fdc 100644 --- a/lib/utils/contact_search.dart +++ b/lib/utils/contact_search.dart @@ -1,5 +1,3 @@ -import 'package:meshcore_open/models/discovery_contact.dart'; - import '../models/contact.dart'; bool matchesContactQuery(Contact contact, String query) { @@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) { return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); } -bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { +bool matchesDiscoveryContactQuery(Contact contact, String query) { final normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.isEmpty) return true; diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index 1f592eb..f122836 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -157,8 +157,11 @@ class _SNRIndicatorState extends State { repeater.snr, widget.connector.currentSf, ); - - final name = widget.connector.contacts + final allContacts = [ + ...widget.connector.contacts, + ...widget.connector.discoveredContacts, + ]; + final name = allContacts .where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .map((c) => c.name) .firstOrNull; From db935a745433367dffb46228c621a81ba8982376 Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 13 Mar 2026 10:58:52 -0700 Subject: [PATCH 09/75] refactor(tcp): promote MeshCoreTcpConnector, fix translations, harden UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace thin MeshCoreTcpManager facade with a proper MeshCoreTcpConnector that owns TcpTransportService and the frame subscription, mirroring MeshCoreUsbManager. The connector no longer holds a raw TcpTransportService or a _tcpFrameSubscription field. - Remove hardcoded default host IP from TcpScreen (keep port 5000 hint). - Disable connect button during scanning state, not just connecting state. - Fix tcpPortLabel mistranslated as nautical "port/harbor" in de, it, pt, nl, sv, sk, sl, zh; fix corrupted Slovak tcpPortHint ("5 000" → "5000"). - Remove unused tcpStatus_connecting string from all 15 locale arb files and all generated app_localizations_*.dart files. - Add extendedPadding to TCP screen FABs to match USB screen. - Add Key to connect button; update tests to use byKey and assert onPressed == null when button is disabled during scanning. --- lib/connector/meshcore_connector.dart | 39 +++++++++----------- lib/connector/meshcore_connector_tcp.dart | 44 ++++++++++++++++++++--- lib/l10n/app_bg.arb | 1 - lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 1 - lib/l10n/app_es.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_it.arb | 3 +- lib/l10n/app_localizations.dart | 6 ---- lib/l10n/app_localizations_bg.dart | 3 -- lib/l10n/app_localizations_de.dart | 6 +--- lib/l10n/app_localizations_en.dart | 3 -- lib/l10n/app_localizations_es.dart | 3 -- lib/l10n/app_localizations_fr.dart | 3 -- lib/l10n/app_localizations_it.dart | 5 +-- lib/l10n/app_localizations_nl.dart | 5 +-- lib/l10n/app_localizations_pl.dart | 3 -- lib/l10n/app_localizations_pt.dart | 6 +--- lib/l10n/app_localizations_ru.dart | 3 -- lib/l10n/app_localizations_sk.dart | 7 ++-- lib/l10n/app_localizations_sl.dart | 5 +-- lib/l10n/app_localizations_sv.dart | 5 +-- lib/l10n/app_localizations_uk.dart | 3 -- lib/l10n/app_localizations_zh.dart | 5 +-- lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 1 - lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 1 - lib/l10n/app_sk.arb | 5 ++- lib/l10n/app_sl.arb | 3 +- lib/l10n/app_sv.arb | 3 +- lib/l10n/app_uk.arb | 1 - lib/l10n/app_zh.arb | 3 +- lib/screens/tcp_screen.dart | 10 ++++-- test/screens/tcp_flow_test.dart | 17 +++++---- 35 files changed, 91 insertions(+), 123 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b800cfa..d974b7b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -116,8 +116,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _manualDisconnect = false; final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); StreamSubscription? _usbFrameSubscription; - final MeshCoreTcpManager _tcpManager = MeshCoreTcpManager(); - StreamSubscription? _tcpFrameSubscription; + final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector(); MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; final List _scanResults = []; @@ -257,7 +256,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get isUsbTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.usb; - String? get activeTcpEndpoint => _tcpManager.activeEndpoint; + String? get activeTcpEndpoint => _tcpConnector.activeEndpoint; bool get isTcpTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.tcp; @@ -666,7 +665,7 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _usbManager.setDebugLogService(_appDebugLogService); - _tcpManager.setDebugLogService(_appDebugLogService); + _tcpConnector.setDebugLogService(_appDebugLogService); // Initialize notification service _notificationService.initialize(); @@ -1002,22 +1001,21 @@ class MeshCoreConnector extends ChangeNotifier { await disconnect(manual: false); return; } - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); + if (_tcpConnector.isConnected) { + await _tcpConnector.disconnect(); } } - await _tcpFrameSubscription?.cancel(); - _tcpFrameSubscription = null; - await _tcpManager.connect(host: host, port: port); + await _tcpConnector.cancelFrameSubscription(); + await _tcpConnector.connect(host: host, port: port); final isTcpConnectCancelled = _activeTransport != MeshCoreTransportType.tcp || _state != MeshCoreConnectionState.connecting || - !_tcpManager.isConnected; + !_tcpConnector.isConnected; if (isTcpConnectCancelled) { await handleTcpConnectAbort( message: - 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}', ); return; } @@ -1027,16 +1025,16 @@ class MeshCoreConnector extends ChangeNotifier { final isTcpConnectCancelledAfterDelay = _activeTransport != MeshCoreTransportType.tcp || _state != MeshCoreConnectionState.connecting || - !_tcpManager.isConnected; + !_tcpConnector.isConnected; if (isTcpConnectCancelledAfterDelay) { await handleTcpConnectAbort( message: - 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}', ); return; } - _tcpFrameSubscription = _tcpManager.frameStream.listen( - _handleFrame, + _tcpConnector.listenFrames( + onFrame: _handleFrame, onError: (error, stackTrace) { _appDebugLogService?.error('TCP transport error: $error', tag: 'TCP'); unawaited(disconnect(manual: false)); @@ -1073,7 +1071,7 @@ class MeshCoreConnector extends ChangeNotifier { manualDisconnect: _manualDisconnect, state: _state, activeTransport: _activeTransport, - tcpManagerConnected: _tcpManager.isConnected, + tcpManagerConnected: _tcpConnector.isConnected, ); if (tcpConnectCancelledBeforeHandshake) { _appDebugLogService?.info( @@ -1445,9 +1443,7 @@ class MeshCoreConnector extends ChangeNotifier { await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; await _usbManager.disconnect(); - await _tcpFrameSubscription?.cancel(); - _tcpFrameSubscription = null; - await _tcpManager.disconnect(); + await _tcpConnector.disconnect(); await _notifySubscription?.cancel(); _notifySubscription = null; @@ -1530,7 +1526,7 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await _usbManager.write(data); } else if (_activeTransport == MeshCoreTransportType.tcp) { - await _tcpManager.write(data); + await _tcpConnector.write(data); } else { if (_rxCharacteristic == null) { throw Exception("MeshCore RX characteristic not available"); @@ -4484,14 +4480,13 @@ class MeshCoreConnector extends ChangeNotifier { _scanSubscription?.cancel(); _connectionSubscription?.cancel(); _usbFrameSubscription?.cancel(); - _tcpFrameSubscription?.cancel(); _notifySubscription?.cancel(); _notifyListenersTimer?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); _receivedFramesController.close(); _usbManager.dispose(); - _tcpManager.dispose(); + _tcpConnector.dispose(); // Flush pending unread writes before disposal _unreadStore.flush(); diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart index 92b98d7..7c93d9f 100644 --- a/lib/connector/meshcore_connector_tcp.dart +++ b/lib/connector/meshcore_connector_tcp.dart @@ -1,34 +1,70 @@ +import 'dart:async'; import 'dart:typed_data'; import '../services/app_debug_log_service.dart'; import '../services/tcp_transport_service.dart'; -class MeshCoreTcpManager { +/// Manages TCP transport for MeshCore devices. +/// +/// Owns the [TcpTransportService] and TCP-specific connection state. +/// The main [MeshCoreConnector] delegates all TCP operations here. +class MeshCoreTcpConnector { final TcpTransportService _service = TcpTransportService(); AppDebugLogService? _debugLog; + StreamSubscription? _frameSubscription; + // --- Getters --- String? get activeEndpoint => _service.activeEndpoint; bool get isConnected => _service.isConnected; - Stream get frameStream => _service.frameStream; + // --- Configuration --- void setDebugLogService(AppDebugLogService? service) { _debugLog = service; _service.setDebugLogService(service); } + // --- Connection lifecycle --- Future connect({required String host, required int port}) async { - _debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP'); + _debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP'); + await _frameSubscription?.cancel(); + _frameSubscription = null; await _service.connect(host: host, port: port); + _debugLog?.info( + 'TcpConnector.connect done, endpoint=${_service.activeEndpoint}', + tag: 'TCP', + ); + } + + StreamSubscription listenFrames({ + required void Function(Uint8List) onFrame, + required void Function(Object, StackTrace?) onError, + required void Function() onDone, + }) { + _frameSubscription = _service.frameStream.listen( + onFrame, + onError: onError, + onDone: onDone, + ); + return _frameSubscription!; + } + + Future cancelFrameSubscription() async { + await _frameSubscription?.cancel(); + _frameSubscription = null; } Future disconnect() async { - _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); + if (!_service.isConnected && _frameSubscription == null) return; + _debugLog?.info('TcpConnector.disconnect', tag: 'TCP'); + await _frameSubscription?.cancel(); + _frameSubscription = null; await _service.disconnect(); } Future write(Uint8List data) => _service.write(data); void dispose() { + _frameSubscription?.cancel(); _service.dispose(); } } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index fa84e50..c5acba9 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Пристанище", "tcpPortHint": "5000", "tcpStatus_notConnected": "Въведете крайната точка и свържете се.", - "tcpStatus_connecting": "Свързване към TCP крайния пункт...", "tcpStatus_connectingTo": "Свързване към {endpoint}...", "tcpErrorHostRequired": "Необходим е IP адрес.", "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eae33e5..2eed922 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1906,10 +1906,9 @@ "connectionChoiceTcpLabel": "TCP", "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "Verbinden über TCP", - "tcpPortLabel": "Hafen", + "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.", - "tcpStatus_connecting": "Verbindung zum TCP-Endpunkt hergestellt...", "tcpStatus_connectingTo": "Verbindung zu {endpoint}...", "tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.", "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0acf9a5..58569e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -56,7 +56,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Enter endpoint and connect", - "tcpStatus_connecting": "Connecting to TCP endpoint...", "tcpStatus_connectingTo": "Connecting to {endpoint}...", "@tcpStatus_connectingTo": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 974e2c3..25e0345 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1909,7 +1909,6 @@ "tcpPortLabel": "Puerto", "tcpPortHint": "5000", "tcpStatus_notConnected": "Ingrese la dirección final y conecte.", - "tcpStatus_connecting": "Conectándose al punto final TCP...", "tcpStatus_connectingTo": "Conectándose a {endpoint}...", "tcpErrorHostRequired": "Se requiere la dirección IP.", "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3c36a96..5d586f4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.", - "tcpStatus_connecting": "Connexion au point de terminaison TCP...", "tcpStatus_connectingTo": "Connexion à {endpoint}...", "tcpErrorHostRequired": "Une adresse IP est obligatoire.", "tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 8a1c29d..4de9e9d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1878,10 +1878,9 @@ "tcpHostHint": "192.168.40.10", "connectionChoiceTcpLabel": "TCP", "tcpScreenTitle": "Stabilire una connessione tramite TCP", - "tcpPortLabel": "Porto", + "tcpPortLabel": "Porta", "tcpPortHint": "5000", "tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.", - "tcpStatus_connecting": "Connessione al punto finale TCP...", "tcpStatus_connectingTo": "Connessione a {endpoint}...", "tcpErrorHostRequired": "È necessario fornire un indirizzo IP.", "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 36be955..3690bf2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -376,12 +376,6 @@ abstract class AppLocalizations { /// **'Enter endpoint and connect'** String get tcpStatus_notConnected; - /// No description provided for @tcpStatus_connecting. - /// - /// In en, this message translates to: - /// **'Connecting to TCP endpoint...'** - String get tcpStatus_connecting; - /// No description provided for @tcpStatus_connectingTo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index fd61be0..785f373 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -138,9 +138,6 @@ class AppLocalizationsBg extends AppLocalizations { @override String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.'; - @override - String get tcpStatus_connecting => 'Свързване към TCP крайния пункт...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Свързване към $endpoint...'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0400237..708e0ab 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -130,7 +130,7 @@ class AppLocalizationsDe extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Hafen'; + String get tcpPortLabel => 'Port'; @override String get tcpPortHint => '5000'; @@ -139,10 +139,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tcpStatus_notConnected => 'Geben Sie den Endpunkt ein und verbinden Sie sich.'; - @override - String get tcpStatus_connecting => - 'Verbindung zum TCP-Endpunkt hergestellt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Verbindung zu $endpoint...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d01dc36..4938dd4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -138,9 +138,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tcpStatus_notConnected => 'Enter endpoint and connect'; - @override - String get tcpStatus_connecting => 'Connecting to TCP endpoint...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connecting to $endpoint...'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 7e5f806..2d4e2fb 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -138,9 +138,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.'; - @override - String get tcpStatus_connecting => 'Conectándose al punto final TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Conectándose a $endpoint...'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c7a225b..28bbab3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -139,9 +139,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tcpStatus_notConnected => 'Entrez l\'adresse de destination et connectez-vous.'; - @override - String get tcpStatus_connecting => 'Connexion au point de terminaison TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connexion à $endpoint...'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 121c2f8..b510bc1 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -130,7 +130,7 @@ class AppLocalizationsIt extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Porto'; + String get tcpPortLabel => 'Porta'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.'; - @override - String get tcpStatus_connecting => 'Connessione al punto finale TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connessione a $endpoint...'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 48fe379..7c054dd 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -130,7 +130,7 @@ class AppLocalizationsNl extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Haven'; + String get tcpPortLabel => 'Poort'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind'; - @override - String get tcpStatus_connecting => 'Verbinding maken met TCP-eindpunt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Verbinding maken met $endpoint...'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 97681be..dec6583 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -138,9 +138,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz'; - @override - String get tcpStatus_connecting => 'Połączenie z punktem TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Połączenie z $endpoint...'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index eb2fc7f..4d8d20e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -130,7 +130,7 @@ class AppLocalizationsPt extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Porto'; + String get tcpPortLabel => 'Porta'; @override String get tcpPortHint => '5000'; @@ -138,10 +138,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.'; - @override - String get tcpStatus_connecting => - 'Conectando ao ponto de extremidade TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Conectando a $endpoint...'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1a28e8d..60aa486 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -138,9 +138,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tcpStatus_notConnected => 'Введите адрес и подключитесь.'; - @override - String get tcpStatus_connecting => 'Установление соединения с TCP-портом...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Подключение к $endpoint...'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index eb53c40..4e11719 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -130,17 +130,14 @@ class AppLocalizationsSk extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Pri항'; + String get tcpPortLabel => 'Port'; @override - String get tcpPortHint => '5 000'; + String get tcpPortHint => '5000'; @override String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.'; - @override - String get tcpStatus_connecting => 'Pripojenie k TCP endpointu...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Pripojenie k $endpoint...'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index be87248..f967db4 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -130,7 +130,7 @@ class AppLocalizationsSl extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Pril'; + String get tcpPortLabel => 'Vrata'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsSl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite'; - @override - String get tcpStatus_connecting => 'Povezava z TCP koncem...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Povezava z $endpoint...'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 55ac5c6..200bdbe 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -130,7 +130,7 @@ class AppLocalizationsSv extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Hamn'; + String get tcpPortLabel => 'Port'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsSv extends AppLocalizations { @override String get tcpStatus_notConnected => 'Ange slutpunkt och anslut'; - @override - String get tcpStatus_connecting => 'Anslutning till TCP-slutpunkt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Anslutning till $endpoint...'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8466adf..8dfe123 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -138,9 +138,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться'; - @override - String get tcpStatus_connecting => 'Підключення до TCP-кінцевої точки...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Підключення до $endpoint...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 85026c2..ecd6813 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -130,7 +130,7 @@ class AppLocalizationsZh extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => '港'; + String get tcpPortLabel => '端口'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tcpStatus_notConnected => '输入目标地址,然后连接'; - @override - String get tcpStatus_connecting => '连接到 TCP 终点...'; - @override String tcpStatus_connectingTo(String endpoint) { return '连接到 $endpoint...'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 51c045c..d38fb4c 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP-adres", "tcpHostHint": "192.168.40.10", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Haven", + "tcpPortLabel": "Poort", "tcpPortHint": "5000", "tcpStatus_notConnected": "Voer het eindpunt in en verbind", - "tcpStatus_connecting": "Verbinding maken met TCP-eindpunt...", "tcpStatus_connectingTo": "Verbinding maken met {endpoint}...", "tcpErrorHostRequired": "Een IP-adres is vereist.", "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index e711a99..9dc3b33 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Wprowadź adres URL i połącz", - "tcpStatus_connecting": "Połączenie z punktem TCP...", "tcpStatus_connectingTo": "Połączenie z {endpoint}...", "tcpErrorHostRequired": "Wymagana jest adresa IP.", "tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6c1ad9e..cded31f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1878,10 +1878,9 @@ "connectionChoiceTcpLabel": "TCP", "tcpScreenTitle": "Estabelecer conexão via TCP", "tcpHostHint": "192.168.40.10", - "tcpPortLabel": "Porto", + "tcpPortLabel": "Porta", "tcpPortHint": "5000", "tcpStatus_notConnected": "Insira o endereço final e conecte-se.", - "tcpStatus_connecting": "Conectando ao ponto de extremidade TCP...", "tcpStatus_connectingTo": "Conectando a {endpoint}...", "tcpErrorHostRequired": "É necessário fornecer um endereço IP.", "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d90c387..43e1b9a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1121,7 +1121,6 @@ "tcpPortLabel": "Порт", "tcpPortHint": "5000", "tcpStatus_notConnected": "Введите адрес и подключитесь.", - "tcpStatus_connecting": "Установление соединения с TCP-портом...", "tcpStatus_connectingTo": "Подключение к {endpoint}...", "tcpErrorHostRequired": "Необходимо указать IP-адрес.", "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index ae55843..f03d276 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP adresa", "tcpScreenTitle": "Spojte sa pomocou protokolu TCP", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Pri항", - "tcpPortHint": "5 000", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", "tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.", - "tcpStatus_connecting": "Pripojenie k TCP endpointu...", "tcpStatus_connectingTo": "Pripojenie k {endpoint}...", "tcpErrorHostRequired": "Je potrebné zadať IP adresu.", "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 6ad2449..4a4b5cb 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP naslov", "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "Komunicirajte preko protokola TCP", - "tcpPortLabel": "Pril", + "tcpPortLabel": "Vrata", "tcpPortHint": "5000", "tcpStatus_notConnected": "Vnesite končni naslov in se povežite", - "tcpStatus_connecting": "Povezava z TCP koncem...", "tcpStatus_connectingTo": "Povezava z {endpoint}...", "tcpErrorHostRequired": "Potrebna je IP-naslov.", "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index b70049f..6a33e11 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP-adress", "tcpScreenTitle": "Anslut via TCP", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Hamn", + "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Ange slutpunkt och anslut", - "tcpStatus_connecting": "Anslutning till TCP-slutpunkt...", "tcpStatus_connectingTo": "Anslutning till {endpoint}...", "tcpErrorHostRequired": "IP-adress krävs.", "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 9dcf88f..c179ca3 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Порт", "tcpPortHint": "5000", "tcpStatus_notConnected": "Введіть кінцеву точку та підключіться", - "tcpStatus_connecting": "Підключення до TCP-кінцевої точки...", "tcpStatus_connectingTo": "Підключення до {endpoint}...", "tcpErrorHostRequired": "Необхідно вказати IP-адресу.", "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 199f85c..cac4b79 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1883,10 +1883,9 @@ "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "通过 TCP 连接", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "港", + "tcpPortLabel": "端口", "tcpPortHint": "5000", "tcpStatus_notConnected": "输入目标地址,然后连接", - "tcpStatus_connecting": "连接到 TCP 终点...", "tcpStatus_connectingTo": "连接到 {endpoint}...", "tcpErrorHostRequired": "需要提供IP地址。", "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 55bec20..cf87382 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -27,7 +27,7 @@ class _TcpScreenState extends State { @override void initState() { super.initState(); - _hostController = TextEditingController(text: '192.168.40.10'); + _hostController = TextEditingController(); _portController = TextEditingController(text: '5000'); _connector = context.read(); @@ -81,6 +81,9 @@ class _TcpScreenState extends State { final isConnecting = connector.state == MeshCoreConnectionState.connecting && connector.activeTransport == MeshCoreTransportType.tcp; + final isButtonDisabled = + isConnecting || + connector.state == MeshCoreConnectionState.scanning; return Column( children: [ _buildStatusBar(context, connector), @@ -112,7 +115,8 @@ class _TcpScreenState extends State { ), const SizedBox(height: 16), FilledButton.icon( - onPressed: isConnecting ? null : _connectTcp, + key: const Key('tcp_connect_button'), + onPressed: isButtonDisabled ? null : _connectTcp, icon: isConnecting ? const SizedBox( width: 18, @@ -153,6 +157,7 @@ class _TcpScreenState extends State { ); }, heroTag: 'tcp_usb_action', + extendedPadding: const EdgeInsets.symmetric(horizontal: 12), icon: const Icon(Icons.usb), label: Text(context.l10n.connectionChoiceUsbLabel), ), @@ -162,6 +167,7 @@ class _TcpScreenState extends State { Navigator.of(context).maybePop(); }, heroTag: 'tcp_ble_action', + extendedPadding: const EdgeInsets.symmetric(horizontal: 12), icon: const Icon(Icons.bluetooth), label: Text(context.l10n.connectionChoiceBluetoothLabel), ), diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 5c240f4..725388a 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -93,7 +93,7 @@ void main() { final l10n = AppLocalizations.of(context); await tester.enterText(find.byType(TextField).first, ''); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.tap(find.byKey(const Key('tcp_connect_button'))); await tester.pumpAndSettle(); expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget); @@ -101,7 +101,7 @@ void main() { await tester.enterText(find.byType(TextField).first, '192.168.1.50'); await tester.enterText(find.byType(TextField).at(1), '99999'); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.tap(find.byKey(const Key('tcp_connect_button'))); await tester.pumpAndSettle(); expect(connector.connectTcpCalls, 0); @@ -135,7 +135,7 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); - testWidgets('TcpScreen allows connect while connector is scanning', ( + testWidgets('TcpScreen disables connect button while connector is scanning', ( tester, ) async { final connector = _FakeMeshCoreConnector() @@ -150,12 +150,11 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); - await tester.pumpAndSettle(); - - expect(connector.connectTcpCalls, 1); - expect(connector.lastHost, '192.168.40.10'); - expect(connector.lastPort, 5000); + final button = tester.widget( + find.byKey(const Key('tcp_connect_button')), + ); + expect(button.onPressed, isNull); + expect(connector.connectTcpCalls, 0); }); testWidgets('TcpScreen narrow width long status text does not overflow', ( From 71f59d23df9fe83ebbc84f9a3a5929850484c41e Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 09:33:37 -0700 Subject: [PATCH 10/75] 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. --- lib/connector/meshcore_connector.dart | 92 +++++++++---- lib/l10n/app_bg.arb | 3 +- lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 3 +- lib/l10n/app_fr.arb | 3 +- lib/l10n/app_it.arb | 3 +- lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 3 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_it.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_sk.dart | 3 + lib/l10n/app_localizations_sl.dart | 3 + lib/l10n/app_localizations_sv.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 3 +- lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_sk.arb | 3 +- lib/l10n/app_sl.arb | 3 +- lib/l10n/app_sv.arb | 3 +- lib/l10n/app_uk.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/screens/chat_screen.dart | 104 ++++++++++---- lib/screens/contacts_screen.dart | 8 ++ lib/screens/map_screen.dart | 16 +++ lib/services/message_retry_service.dart | 173 +++++++++++++++++++----- lib/services/notification_service.dart | 55 ++++++++ 37 files changed, 434 insertions(+), 108 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d974b7b..7cf32ef 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -199,6 +199,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 +561,10 @@ class MeshCoreConnector extends ChangeNotifier { _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), ); + _notificationService.clearContactNotification( + contactKeyHex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -576,6 +583,10 @@ class MeshCoreConnector extends ChangeNotifier { _channels.isNotEmpty ? _channels : _cachedChannels, ), ); + _notificationService.clearChannelNotification( + channelIndex, + getTotalUnreadCount(), + ); notifyListeners(); } } @@ -1740,18 +1751,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 +2162,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( @@ -2490,6 +2525,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 diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index c5acba9..07c6ae9 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1887,5 +1887,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 2eed922..0e3b5f4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1915,5 +1915,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 58569e4..712a164 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -807,6 +807,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 25e0345..53d7b70 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1915,5 +1915,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 5d586f4..ef19d15 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1887,5 +1887,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 4de9e9d..a9f659b 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1887,5 +1887,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 3690bf2..fdc89d4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2746,6 +2746,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 785f373..e17056c 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1511,6 +1511,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 708e0ab..68317c8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1513,6 +1513,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 4938dd4..95e8130 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1487,6 +1487,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 2d4e2fb..fad2737 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1509,6 +1509,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 28bbab3..b11b373 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1518,6 +1518,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 b510bc1..d1b2d95 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1510,6 +1510,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 7c054dd..47e2341 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1502,6 +1502,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 dec6583..a8abd13 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1512,6 +1512,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 4d8d20e..54facda 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1511,6 +1511,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 60aa486..beb37de 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1513,6 +1513,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 4e11719..c59014c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1504,6 +1504,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 f967db4..4746550 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1498,6 +1498,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 200bdbe..d86f994 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1494,6 +1494,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 8dfe123..e40dd88 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1510,6 +1510,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 ecd6813..d5f4f1d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1421,6 +1421,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 d38fb4c..9512f18 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1887,5 +1887,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 9dc3b33..88856aa 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1887,5 +1887,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 cded31f..3a5fe3b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1887,5 +1887,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 43e1b9a..8fec7fa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1127,5 +1127,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 f03d276..374e9e4 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1887,5 +1887,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 4a4b5cb..2436c78 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1887,5 +1887,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 6a33e11..75231d0 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1887,5 +1887,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 c179ca3..35d7bd2 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1887,5 +1887,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 cac4b79..9e7c155 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1892,5 +1892,6 @@ "tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorTimedOut": "TCP 连接超时。", "tcpConnectionFailed": "TCP 连接失败:{error}", - "map_showDiscoveryContacts": "显示发现联系人" + "map_showDiscoveryContacts": "显示发现联系人", + "map_setAsMyLocation": "设置为我的位置" } 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 3fef9ec..243c8c4 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'; @@ -64,6 +65,13 @@ class _ContactsScreenState extends State super.initState(); _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..402a4ee 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1509,6 +1509,22 @@ 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 message = context.l10n.settings_locationUpdated; + Navigator.pop(sheetContext); + await connector.setNodeLocation( + lat: position.latitude, + lon: position.longitude, + ); + await connector.refreshDeviceInfo(); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(message))); + }, + ), 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..df81d16 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) @@ -156,7 +162,40 @@ 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); + 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 +208,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 +229,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 +286,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 +345,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 +355,28 @@ class MessageRetryService extends ChangeNotifier { 'Hash-based match failed for $ackHashHex, falling back to queue-based matching', ); - for (var entry in _pendingMessageQueuePerContact.entries) { + // Try to identify the correct contact from _activeMessages first. + String? targetContactKey; + for (final activeId in _activeMessages) { + final activeContact = _pendingContacts[activeId]; + if (activeContact != null) { + targetContactKey = activeContact.publicKeyHex; + break; + } + } + + final queuesToSearch = targetContactKey != null + ? {targetContactKey: _pendingMessageQueuePerContact[targetContactKey]} + : _pendingMessageQueuePerContact; + + for (var entry in queuesToSearch.entries) { final contactKey = entry.key; final queue = entry.value; + if (queue == null) continue; - 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 +384,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; } } @@ -463,22 +532,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 +553,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 +672,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 +702,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 +741,7 @@ class MessageRetryService extends ChangeNotifier { true, tripTimeMs, ); + _onMessageResolved(matchedMessageId, contact.publicKeyHex); } notifyListeners(); @@ -783,6 +879,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..4ed59d0 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -331,6 +331,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: 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) // ───────────────────────────────────────────────────────────────── From 91608ff09e248fd96398cd040e90893ecb80c91c Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 09:44:37 -0700 Subject: [PATCH 11/75] feat: improve message matching logic and update notification IDs for advertisements --- .swift-version | 1 + lib/services/message_retry_service.dart | 16 ++-------------- lib/services/notification_service.dart | 6 ++++-- 3 files changed, 7 insertions(+), 16 deletions(-) create mode 100644 .swift-version 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/services/message_retry_service.dart b/lib/services/message_retry_service.dart index df81d16..56d7b82 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -355,24 +355,12 @@ class MessageRetryService extends ChangeNotifier { 'Hash-based match failed for $ackHashHex, falling back to queue-based matching', ); - // Try to identify the correct contact from _activeMessages first. - String? targetContactKey; - for (final activeId in _activeMessages) { - final activeContact = _pendingContacts[activeId]; - if (activeContact != null) { - targetContactKey = activeContact.publicKeyHex; - break; - } - } - - final queuesToSearch = targetContactKey != null - ? {targetContactKey: _pendingMessageQueuePerContact[targetContactKey]} - : _pendingMessageQueuePerContact; + // 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 == null) continue; // Drain stale entries until we find a valid one or exhaust the queue. while (queue.isNotEmpty) { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 4ed59d0..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, @@ -355,7 +357,7 @@ class NotificationService { Future clearAdvertNotifications(List contactIds) async { if (!await _ensureInitialized()) return; for (final id in contactIds) { - await _notifications.cancel(id: id.hashCode); + await _notifications.cancel(id: 'advert:$id'.hashCode); } } From fa4da979af2c7ad4cead7b06d7b8d32528e838b6 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 09:54:50 -0700 Subject: [PATCH 12/75] feat: enhance location update feedback and improve message retry error handling --- lib/screens/map_screen.dart | 5 +++-- lib/services/message_retry_service.dart | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 402a4ee..1dd3a5f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1514,15 +1514,16 @@ class _MapScreenState extends State { title: Text(context.l10n.map_setAsMyLocation), onTap: () async { final messenger = ScaffoldMessenger.of(context); - final message = context.l10n.settings_locationUpdated; + 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(message))); + messenger.showSnackBar(SnackBar(content: Text(successMsg))); }, ), ListTile( diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 56d7b82..db4475f 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -184,7 +184,16 @@ class MessageRetryService extends ChangeNotifier { final messageId = queue.removeAt(0); if (_pendingMessages.containsKey(messageId)) { _activeMessages.add(messageId); - _attemptSend(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 From 79a45c527b7293d393d37160049c17ff78b56d03 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 11:45:47 -0700 Subject: [PATCH 13/75] Unify contact retrieval by introducing allContacts getter --- lib/connector/meshcore_connector.dart | 17 +++++++++++++++-- lib/screens/channel_message_path_screen.dart | 10 ++-------- lib/screens/chat_screen.dart | 2 +- lib/screens/map_screen.dart | 5 +---- lib/screens/neighbors_screen.dart | 5 +---- lib/screens/path_trace_map.dart | 5 +---- lib/widgets/path_management_dialog.dart | 2 +- lib/widgets/path_selection_dialog.dart | 3 ++- lib/widgets/snr_indicator.dart | 5 +---- 9 files changed, 25 insertions(+), 29 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cf32ef..dad5ed1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -289,6 +289,10 @@ class MeshCoreConnector extends ChangeNotifier { ); } + List get allContacts => List.unmodifiable([ + ..._contacts, + ..._discoveredContacts.where((c) => !c.isActive), + ]); List get discoveredContacts { return List.unmodifiable(_discoveredContacts); } @@ -2909,6 +2913,8 @@ class MeshCoreConnector extends ChangeNotifier { 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( @@ -4717,6 +4723,12 @@ class MeshCoreConnector extends ChangeNotifier { (_autoAddRoomServers && type == advTypeRoom) || (_autoAddSensors && type == advTypeSensor)) { _handleContactAdvert(newContact); + _handleDiscovery( + newContact, + rawPacket, + noNotify: true, + addActive: true, + ); } else { _handleDiscovery(newContact, rawPacket); } @@ -4827,6 +4839,7 @@ class MeshCoreConnector extends ChangeNotifier { Contact contact, Uint8List rawPacket, { bool noNotify = false, + bool addActive = false, }) { appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector'); @@ -4847,7 +4860,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude: contact.longitude, lastSeen: contact.lastSeen, flags: 0, - isActive: false, + isActive: addActive, ); notifyListeners(); unawaited(_persistDiscoveredContacts()); @@ -4865,7 +4878,7 @@ class MeshCoreConnector extends ChangeNotifier { longitude: contact.longitude, lastSeen: contact.lastSeen, lastMessageAt: contact.lastMessageAt, - isActive: false, + isActive: addActive, flags: 0, ); _discoveredContacts.add(disContact); diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index c2c57f0..32eadef 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; final hops = _buildPathHops(primaryPath, contacts, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( @@ -367,10 +364,7 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; final hops = _buildPathHops(selectedPath, contacts, context.l10n); final points = []; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 96203ea..6558ecd 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1027,7 +1027,7 @@ class _ChatScreenState extends State { 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(); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 1dd3a5f..497c05f 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -137,10 +137,7 @@ class _MapScreenState extends State { builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final settings = settingsService.settings; - final allContacts = [ - ...connector.contacts, - ...connector.discoveredContacts.where((c) => !c.isActive), - ]; + final allContacts = connector.allContacts; final contacts = settings.mapShowDiscoveryContacts ? allContacts diff --git a/lib/screens/neighbors_screen.dart b/lib/screens/neighbors_screen.dart index 5cb8e45..5afeda4 100644 --- a/lib/screens/neighbors_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -124,10 +124,7 @@ class _NeighborsScreenState extends State { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { final buffer = BufferReader(frame); - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; try { final neighborCount = buffer.readUInt16LE(); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index ceb60a6..6277886 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -263,10 +263,7 @@ class _PathTraceMapScreenState extends State { .toList(); Map pathContacts = {}; - final contacts = [ - ...connector.contacts, - ...connector.discoveredContacts, - ]; + final contacts = connector.allContacts; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { for (var repeaterData in pathData) { if (listEquals( diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 384f92b..0233b43 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -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(); diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 4e6cfe5..b1733fc 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -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 { void _filterValidContacts() { _validContacts = widget.availableContacts - .where((c) => c.type == 2 || c.type == 3) + .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom) .toList(); } diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index f122836..30956e2 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -157,10 +157,7 @@ class _SNRIndicatorState extends State { 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) From 24fa78741b2fb8094be33dbabea974e801f2eee9 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 11:46:05 -0700 Subject: [PATCH 14/75] add TCP server address and port settings to AppSettings and update TcpScreen --- lib/models/app_settings.dart | 12 ++++++++++++ lib/screens/tcp_screen.dart | 18 ++++++++++++++++-- lib/services/app_settings_service.dart | 8 ++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index c89ac27..fc84851 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -40,6 +40,8 @@ class AppSettings { final UnitSystem unitSystem; final Set 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? 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? 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, ); } } diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index cf87382..02b9b5a 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:meshcore_open/models/app_settings.dart'; 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 +29,14 @@ class _TcpScreenState extends State { @override void initState() { super.initState(); - _hostController = TextEditingController(); - _portController = TextEditingController(text: '5000'); + _hostController = TextEditingController( + text: context.read().settings.tcpServerAddress, + ); + _portController = TextEditingController( + text: context.read().settings.tcpServerPort > 0 + ? context.read().settings.tcpServerPort.toString() + : '', + ); _connector = context.read(); _connectionListener = () { @@ -39,6 +47,12 @@ class _TcpScreenState extends State { if (_connector.state == MeshCoreConnectionState.connected && _connector.isTcpTransportConnected && !_navigatedToContacts) { + context.read().setTcpServerAddress( + _hostController.text, + ); + context.read().setTcpServerPort( + int.tryParse(_portController.text) ?? 0, + ); _navigatedToContacts = true; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const ContactsScreen()), diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index a52e364..88c1f81 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier { ..remove(channelName); await updateSettings(_settings.copyWith(mutedChannels: updated)); } + + Future setTcpServerAddress(String value) async { + await updateSettings(_settings.copyWith(tcpServerAddress: value)); + } + + Future setTcpServerPort(int value) async { + await updateSettings(_settings.copyWith(tcpServerPort: value)); + } } From 86e9b7fe0135e2132c138d39b1d4ecb56d71b013 Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 15 Mar 2026 00:34:09 +0100 Subject: [PATCH 15/75] squashed commit of ez_group_dropdown --- lib/l10n/app_bg.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_bg.dart | 3 + lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_es.dart | 4 + lib/l10n/app_localizations_fr.dart | 3 + lib/l10n/app_localizations_it.dart | 3 + lib/l10n/app_localizations_nl.dart | 3 + lib/l10n/app_localizations_pl.dart | 3 + lib/l10n/app_localizations_pt.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_localizations_sk.dart | 3 + lib/l10n/app_localizations_sl.dart | 3 + lib/l10n/app_localizations_sv.dart | 3 + lib/l10n/app_localizations_uk.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_sk.arb | 1 + lib/l10n/app_sl.arb | 1 + lib/l10n/app_sv.arb | 1 + lib/l10n/app_uk.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/main.dart | 7 + lib/screens/channels_screen.dart | 99 ++- lib/screens/contacts_screen.dart | 730 +++++++++++------- lib/services/chat_text_scale_service.dart | 2 +- lib/services/ui_view_state_service.dart | 154 ++++ lib/utils/contact_search.dart | 4 + lib/widgets/list_filter_widget.dart | 39 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + 39 files changed, 743 insertions(+), 361 deletions(-) create mode 100644 lib/services/ui_view_state_service.dart diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index c5acba9..ba97771 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Нова група", "contacts_groupName": "Група", "contacts_groupNameRequired": "Името на групата е задължително.", + "contacts_groupNameReserved": "Това име на група е запазено", "contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2eed922..3b623ba 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Neue Gruppe", "contacts_groupName": "Gruppenname", "contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.", + "contacts_groupNameReserved": "Dieser Gruppenname ist reserviert", "contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58569e4..96060a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -416,6 +416,7 @@ "contacts_newGroup": "New Group", "contacts_groupName": "Group name", "contacts_groupNameRequired": "Group name is required", + "contacts_groupNameReserved": "This group name is reserved", "contacts_groupAlreadyExists": "Group \"{name}\" already exists", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 25e0345..7cf7898 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nuevo Grupo", "contacts_groupName": "Nombre del grupo", "contacts_groupNameRequired": "El nombre del grupo es obligatorio", + "contacts_groupNameReserved": "Este nombre de grupo está reservado", "contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5d586f4..bbd488c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nouveau Groupe", "contacts_groupName": "Nom du groupe", "contacts_groupNameRequired": "Le nom du groupe est obligatoire.", + "contacts_groupNameReserved": "Ce nom de groupe est réservé", "contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 4de9e9d..06dbd12 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nuovo Gruppo", "contacts_groupName": "Nome gruppo", "contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.", + "contacts_groupNameReserved": "Questo nome del gruppo è riservato", "contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3690bf2..b278e36 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1714,6 +1714,12 @@ abstract class AppLocalizations { /// **'Group name is required'** String get contacts_groupNameRequired; + /// No description provided for @contacts_groupNameReserved. + /// + /// In en, this message translates to: + /// **'This group name is reserved'** + String get contacts_groupNameReserved; + /// No description provided for @contacts_groupAlreadyExists. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 785f373..8b6c121 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -902,6 +902,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_groupNameRequired => 'Името на групата е задължително.'; + @override + String get contacts_groupNameReserved => 'Това име на група е запазено'; + @override String contacts_groupAlreadyExists(String name) { return 'Групата \"$name\" вече съществува.'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 708e0ab..1aa2109 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -902,6 +902,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.'; + @override + String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert'; + @override String contacts_groupAlreadyExists(String name) { return 'Die Gruppe \"$name\" existiert bereits.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4938dd4..255f12b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -889,6 +889,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_groupNameRequired => 'Group name is required'; + @override + String get contacts_groupNameReserved => 'This group name is reserved'; + @override String contacts_groupAlreadyExists(String name) { return 'Group \"$name\" already exists'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2d4e2fb..36efdb3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -901,6 +901,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio'; + @override + String get contacts_groupNameReserved => + 'Este nombre de grupo está reservado'; + @override String contacts_groupAlreadyExists(String name) { return 'El grupo \"$name\" ya existe'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 28bbab3..ee76be3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -905,6 +905,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.'; + @override + String get contacts_groupNameReserved => 'Ce nom de groupe est réservé'; + @override String contacts_groupAlreadyExists(String name) { return 'Le groupe \"$name\" existe déjà.'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b510bc1..6566d6a 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -901,6 +901,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.'; + @override + String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato'; + @override String contacts_groupAlreadyExists(String name) { return 'Il gruppo \"$name\" esiste già.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 7c054dd..99ca553 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -895,6 +895,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_groupNameRequired => 'De groepnaam is verplicht.'; + @override + String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd'; + @override String contacts_groupAlreadyExists(String name) { return 'De groep \"$name\" bestaat al.'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index dec6583..353f448 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -904,6 +904,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana'; + @override + String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona'; + @override String contacts_groupAlreadyExists(String name) { return 'Grupa \"$name\" już istnieje'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4d8d20e..8427a49 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -903,6 +903,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.'; + @override + String get contacts_groupNameReserved => 'Este nome de grupo está reservado'; + @override String contacts_groupAlreadyExists(String name) { return 'O grupo \"$name\" já existe'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 60aa486..74036a2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -902,6 +902,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contacts_groupNameRequired => 'Имя группы обязательно'; + @override + String get contacts_groupNameReserved => 'Это имя группы зарезервировано'; + @override String contacts_groupAlreadyExists(String name) { return 'Группа \"$name\" уже существует'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4e11719..de01520 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -894,6 +894,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_groupNameRequired => 'Skupina musí mať názov.'; + @override + String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný'; + @override String contacts_groupAlreadyExists(String name) { return 'Skupina \"$name\" už existuje'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f967db4..cfe7427 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -892,6 +892,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_groupNameRequired => 'Ime skupine je obvezno.'; + @override + String get contacts_groupNameReserved => 'To ime skupine je rezervirano'; + @override String contacts_groupAlreadyExists(String name) { return 'Skupina \"$name\" že obstaja'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 200bdbe..93b8917 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -888,6 +888,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt'; + @override + String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat'; + @override String contacts_groupAlreadyExists(String name) { return 'Gruppen \"$name\" finns redan.'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8dfe123..0db473c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -898,6 +898,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_groupNameRequired => 'Назва групи обов\'язкова.'; + @override + String get contacts_groupNameReserved => 'Ця назва групи зарезервована'; + @override String contacts_groupAlreadyExists(String name) { return 'Група «$name» вже існує.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ecd6813..55a4063 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -845,6 +845,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_groupNameRequired => '请输入群聊名称'; + @override + String get contacts_groupNameReserved => '该群组名称已被保留'; + @override String contacts_groupAlreadyExists(String name) { return '名为 \"$name\" 的群聊已存在'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d38fb4c..427b998 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nieuwe Groep", "contacts_groupName": "Groepnaam", "contacts_groupNameRequired": "De groepnaam is verplicht.", + "contacts_groupNameReserved": "Deze groepsnaam is gereserveerd", "contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9dc3b33..ab980ff 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nowa Grupa", "contacts_groupName": "Nazwa grupy", "contacts_groupNameRequired": "Nazwa grupy jest wymagana", + "contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona", "contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cded31f..e53649a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Novo Grupo", "contacts_groupName": "Nome do grupo", "contacts_groupNameRequired": "O nome do grupo é obrigatório.", + "contacts_groupNameReserved": "Este nome de grupo está reservado", "contacts_groupAlreadyExists": "O grupo \"{name}\" já existe", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 43e1b9a..00b71d0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -212,6 +212,7 @@ "contacts_newGroup": "Новая группа", "contacts_groupName": "Имя группы", "contacts_groupNameRequired": "Имя группы обязательно", + "contacts_groupNameReserved": "Это имя группы зарезервировано", "contacts_groupAlreadyExists": "Группа \"{name}\" уже существует", "contacts_filterContacts": "Фильтр контактов...", "contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f03d276..c05a171 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nová skupina", "contacts_groupName": "Názov skupiny", "contacts_groupNameRequired": "Skupina musí mať názov.", + "contacts_groupNameReserved": "Tento názov skupiny je rezervovaný", "contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 4a4b5cb..e521ed2 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Nova skupina", "contacts_groupName": "Ime skupine", "contacts_groupNameRequired": "Ime skupine je obvezno.", + "contacts_groupNameReserved": "To ime skupine je rezervirano", "contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6a33e11..c2a538d 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -285,6 +285,7 @@ "contacts_newGroup": "Ny grupp", "contacts_groupName": "Gruppnamn", "contacts_groupNameRequired": "Gruppnamnet är obligatoriskt", + "contacts_groupNameReserved": "Detta gruppnamn är reserverat", "contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c179ca3..3d265dc 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -286,6 +286,7 @@ "contacts_newGroup": "Нова група", "contacts_groupName": "Назва групи", "contacts_groupNameRequired": "Назва групи обов'язкова.", + "contacts_groupNameReserved": "Ця назва групи зарезервована", "contacts_groupAlreadyExists": "Група «{name}» вже існує.", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cac4b79..26fc6e6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -300,6 +300,7 @@ "contacts_newGroup": "新建群聊", "contacts_groupName": "群聊名称", "contacts_groupNameRequired": "请输入群聊名称", + "contacts_groupNameReserved": "该群组名称已被保留", "contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在", "@contacts_groupAlreadyExists": { "placeholders": { diff --git a/lib/main.dart b/lib/main.dart index 9e53e21..1ad1989 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart'; 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 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -39,6 +40,7 @@ void main() async { final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); final chatTextScaleService = ChatTextScaleService(); + final uiViewStateService = UiViewStateService(); // Load settings await appSettingsService.loadSettings(); @@ -56,6 +58,7 @@ void main() async { _registerThirdPartyLicenses(); await chatTextScaleService.initialize(); + await uiViewStateService.initialize(); // Wire up connector with services connector.initialize( @@ -86,6 +89,7 @@ void main() async { appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, chatTextScaleService: chatTextScaleService, + uiViewStateService: uiViewStateService, ), ); } @@ -121,6 +125,7 @@ class MeshCoreApp extends StatelessWidget { final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; final ChatTextScaleService chatTextScaleService; + final UiViewStateService uiViewStateService; const MeshCoreApp({ super.key, @@ -133,6 +138,7 @@ class MeshCoreApp extends StatelessWidget { required this.appDebugLogService, required this.mapTileCacheService, required this.chatTextScaleService, + required this.uiViewStateService, }); @override @@ -146,6 +152,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: chatTextScaleService), + ChangeNotifierProvider.value(value: uiViewStateService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index b56b563..98694be 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -11,6 +11,7 @@ import 'package:uuid/uuid.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; +import '../services/ui_view_state_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; @@ -28,8 +29,6 @@ import 'contacts_screen.dart'; import 'map_screen.dart'; import 'settings_screen.dart'; -enum ChannelSortOption { manual, name, latestMessages, unread } - class ChannelsScreen extends StatefulWidget { final bool hideBackButton; @@ -43,9 +42,7 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - String _searchQuery = ''; Timer? _searchDebounce; - ChannelSortOption _sortOption = ChannelSortOption.manual; List _communities = []; // Cache of PSK hex -> Community for quick lookup @@ -56,6 +53,9 @@ class _ChannelsScreenState extends State @override void initState() { super.initState(); + _searchController.text = context + .read() + .channelsSearchText; WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getChannels(); _loadCommunities(); @@ -110,6 +110,7 @@ class _ChannelsScreenState extends State @override Widget build(BuildContext context) { final connector = context.watch(); + final viewState = context.watch(); final channelMessageStore = ChannelMessageStore(); channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; @@ -205,6 +206,7 @@ class _ChannelsScreenState extends State final filteredChannels = _filterAndSortChannels( channels, connector, + viewState, ); return Column( @@ -219,17 +221,19 @@ class _ChannelsScreenState extends State suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - if (_searchQuery.isNotEmpty) + if (viewState.channelsSearchText.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; _searchController.clear(); - setState(() { - _searchQuery = ''; - }); + context + .read() + .setChannelsSearchText(''); }, ), - _buildFilterButton(), + _buildFilterButton(viewState), ], ), border: OutlineInputBorder( @@ -246,9 +250,9 @@ class _ChannelsScreenState extends State const Duration(milliseconds: 300), () { if (!mounted) return; - setState(() { - _searchQuery = value.toLowerCase(); - }); + context + .read() + .setChannelsSearchText(value); }, ); }, @@ -283,8 +287,9 @@ class _ChannelsScreenState extends State ), ], ) - : (_sortOption == ChannelSortOption.manual && - _searchQuery.isEmpty) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) ? ReorderableListView.builder( padding: const EdgeInsets.only( left: 16, @@ -584,59 +589,40 @@ class _ChannelsScreenState extends State await showDisconnectDialog(context, connector); } - Widget _buildFilterButton() { - const actionSortManual = 0; - const actionSortName = 1; - const actionSortLatest = 2; - const actionSortUnread = 3; - - return SortFilterMenu( + Widget _buildFilterButton(UiViewStateService viewState) { + return SortFilterMenu( tooltip: context.l10n.listFilter_tooltip, sections: [ - SortFilterMenuSection( + SortFilterMenuSection( title: context.l10n.channels_sortBy, options: [ - SortFilterMenuOption( - value: actionSortManual, + SortFilterMenuOption( + value: ChannelSortOption.manual, label: context.l10n.channels_sortManual, - checked: _sortOption == ChannelSortOption.manual, + checked: viewState.channelsSortOption == ChannelSortOption.manual, ), - SortFilterMenuOption( - value: actionSortName, + SortFilterMenuOption( + value: ChannelSortOption.name, label: context.l10n.channels_sortAZ, - checked: _sortOption == ChannelSortOption.name, + checked: viewState.channelsSortOption == ChannelSortOption.name, ), - SortFilterMenuOption( - value: actionSortLatest, + SortFilterMenuOption( + value: ChannelSortOption.latestMessages, label: context.l10n.channels_sortLatestMessages, - checked: _sortOption == ChannelSortOption.latestMessages, + checked: + viewState.channelsSortOption == + ChannelSortOption.latestMessages, ), - SortFilterMenuOption( - value: actionSortUnread, + SortFilterMenuOption( + value: ChannelSortOption.unread, label: context.l10n.channels_sortUnread, - checked: _sortOption == ChannelSortOption.unread, + checked: viewState.channelsSortOption == ChannelSortOption.unread, ), ], ), ], - onSelected: (action) { - setState(() { - switch (action) { - case actionSortManual: - _sortOption = ChannelSortOption.manual; - break; - case actionSortLatest: - _sortOption = ChannelSortOption.latestMessages; - break; - case actionSortUnread: - _sortOption = ChannelSortOption.unread; - break; - case actionSortName: - default: - _sortOption = ChannelSortOption.name; - break; - } - }); + onSelected: (sortOption) { + viewState.setChannelsSortOption(sortOption); }, ); } @@ -644,11 +630,14 @@ class _ChannelsScreenState extends State List _filterAndSortChannels( List channels, MeshCoreConnector connector, + UiViewStateService viewState, ) { var filtered = channels.where((channel) { - if (_searchQuery.isEmpty) return true; + if (viewState.channelsSearchText.isEmpty) return true; final label = _normalizeChannelName(channel); - return label.toLowerCase().contains(_searchQuery); + return label.toLowerCase().contains( + viewState.channelsSearchText.toLowerCase(), + ); }).toList(); int compareByName(Channel a, Channel b) { @@ -657,7 +646,7 @@ class _ChannelsScreenState extends State return nameA.toLowerCase().compareTo(nameB.toLowerCase()); } - switch (_sortOption) { + switch (viewState.channelsSortOption) { case ChannelSortOption.manual: break; case ChannelSortOption.latestMessages: diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 3fef9ec..abb29fa 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -12,8 +12,9 @@ import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; import '../models/contact.dart'; import '../models/contact_group.dart'; -import '../storage/contact_group_store.dart'; +import '../services/ui_view_state_service.dart'; import '../utils/contact_search.dart'; +import '../storage/contact_group_store.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; @@ -47,12 +48,10 @@ class ContactsScreen extends StatefulWidget { class _ContactsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); - String _searchQuery = ''; - ContactSortOption _sortOption = ContactSortOption.lastSeen; - bool _showUnreadOnly = false; - ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); + MeshCoreConnector? _scopeSyncConnector; List _groups = []; + String _loadedGroupScopeKeyHex = ''; Timer? _searchDebounce; final Set _pendingOperations = {}; @@ -62,30 +61,91 @@ class _ContactsScreenState extends State @override void initState() { super.initState(); + _searchController.text = context + .read() + .contactsSearchText; _loadGroups(); _setupFrameListener(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final connector = context.read(); + if (!identical(_scopeSyncConnector, connector)) { + _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); + _scopeSyncConnector = connector; + _scopeSyncConnector?.addListener(_handleConnectorScopeChange); + } + _handleConnectorScopeChange(); + } + @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); _frameSubscription?.cancel(); + _scopeSyncConnector?.removeListener(_handleConnectorScopeChange); super.dispose(); } + void _handleConnectorScopeChange() { + final connector = _scopeSyncConnector; + if (connector == null) return; + _syncGroupScopeIfNeeded(connector); + } + Future _loadGroups() async { + final selfPublicKeyHex = context.read().selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty) { + return; + } + _groupStore.setPublicKeyHex = selfPublicKeyHex; final groups = await _groupStore.loadGroups(); if (!mounted) return; setState(() { + _loadedGroupScopeKeyHex = selfPublicKeyHex; _groups = groups; + _ensureValidSelectedGroup(); }); } Future _saveGroups() async { + final selfPublicKeyHex = context.read().selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty) { + return; + } + _groupStore.setPublicKeyHex = selfPublicKeyHex; await _groupStore.saveGroups(_groups); } + bool _hasGroupStoreScope(MeshCoreConnector connector) { + return connector.selfPublicKeyHex.isNotEmpty; + } + + void _syncGroupScopeIfNeeded(MeshCoreConnector connector) { + final selfPublicKeyHex = connector.selfPublicKeyHex; + if (selfPublicKeyHex.isEmpty || + selfPublicKeyHex == _loadedGroupScopeKeyHex) { + return; + } + _loadGroups(); + } + + void _collapseContactsSearch(UiViewStateService viewState) { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + viewState.setContactsSearchText(''); + viewState.setContactsSearchExpanded(false); + } + + void _showGroupsUnavailableMessage(BuildContext context) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.common_loading))); + } + void _setupFrameListener() { final connector = Provider.of(context, listen: false); // Listen for incoming text messages from the repeater @@ -375,31 +435,163 @@ class _ContactsScreenState extends State await showDisconnectDialog(context, connector); } - Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) { + ContactGroup? _selectedGroupForName(String selectedGroupName) { + if (selectedGroupName == contactsAllGroupsValue) return null; + for (final group in _groups) { + if (group.name == selectedGroupName) return group; + } + return null; + } + + void _ensureValidSelectedGroup() { + final viewState = context.read(); + if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return; + final exists = _groups.any( + (group) => group.name == viewState.contactsSelectedGroupName, + ); + if (!exists) { + viewState.setContactsSelectedGroupName(contactsAllGroupsValue); + } + } + + void _closeDropdownAndRun(BuildContext context, VoidCallback action) { + Navigator.of(context).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + action(); + }); + } + + Widget _buildFilterButton( + BuildContext context, + UiViewStateService viewState, + ) { return ContactsFilterMenu( - sortOption: _sortOption, - typeFilter: _typeFilter, - showUnreadOnly: _showUnreadOnly, + sortOption: viewState.contactsSortOption, + typeFilter: viewState.contactsTypeFilter, + showUnreadOnly: viewState.contactsShowUnreadOnly, onSortChanged: (value) { - setState(() { - _sortOption = value; - }); + viewState.setContactsSortOption(value); }, onTypeFilterChanged: (value) { - setState(() { - _typeFilter = value; - }); + viewState.setContactsTypeFilter(value); }, onUnreadOnlyChanged: (value) { - setState(() { - _showUnreadOnly = value; - }); + viewState.setContactsShowUnreadOnly(value); }, - onNewGroup: () => _showGroupEditor(context, connector.contacts), + ); + } + + Widget _buildGroupButton( + BuildContext context, + MeshCoreConnector connector, + UiViewStateService viewState, + List contacts, + List sortedGroups, + ) { + final canManageGroups = _hasGroupStoreScope(connector); + final selectedGroupName = + _selectedGroupForName(viewState.contactsSelectedGroupName)?.name ?? + context.l10n.listFilter_all; + final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp( + 0.0, + double.infinity, + ); + + return PopupMenuButton( + position: PopupMenuPosition.under, + constraints: BoxConstraints.tightFor(width: menuWidth), + onSelected: (String value) { + viewState.setContactsSelectedGroupName(value); + }, + itemBuilder: (menuContext) => [ + PopupMenuItem( + value: contactsAllGroupsValue, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(menuContext.l10n.listFilter_all), + IconButton( + tooltip: menuContext.l10n.contacts_newGroup, + icon: const Icon(Icons.group_add, size: 20), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _showGroupEditor(this.context, contacts), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + ], + ), + ), + ...sortedGroups.map((group) { + return PopupMenuItem( + value: group.name, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(group.name, overflow: TextOverflow.ellipsis), + ), + IconButton( + tooltip: menuContext.l10n.contacts_editGroup, + icon: const Icon(Icons.edit, size: 20), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _showGroupEditor( + this.context, + contacts, + group: group, + ), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: menuContext.l10n.contacts_deleteGroup, + icon: const Icon(Icons.delete, size: 20, color: Colors.red), + onPressed: canManageGroups + ? () => _closeDropdownAndRun( + menuContext, + () => _confirmDeleteGroup(this.context, group), + ) + : () => _closeDropdownAndRun( + menuContext, + () => _showGroupsUnavailableMessage(this.context), + ), + ), + ], + ), + ); + }), + ], + child: SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text(selectedGroupName, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), ); } Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { + final viewState = context.watch(); final contacts = connector.contacts; final shouldShowStartupSpinner = contacts.isEmpty && @@ -421,92 +613,171 @@ class _ContactsScreenState extends State ); } - final filteredAndSorted = _filterAndSortContacts(contacts, connector); - final filteredGroups = _showUnreadOnly - ? const [] - : _filterAndSortGroups(_groups, contacts); + final filteredAndSorted = _filterAndSortContacts( + contacts, + connector, + viewState, + ); String hintText = ""; - switch (_typeFilter) { + switch (viewState.contactsTypeFilter) { case ContactTypeFilter.all: hintText = context.l10n.contacts_searchContacts( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.users: hintText = context.l10n.contacts_searchUsers( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.repeaters: hintText = context.l10n.contacts_searchRepeaters( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.rooms: hintText = context.l10n.contacts_searchRoomServers( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; case ContactTypeFilter.favorites: hintText = context.l10n.contacts_searchFavorites( filteredAndSorted.length, - _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", + viewState.contactsShowUnreadOnly + ? " ${context.l10n.contacts_unread}" + : "", ); break; } + final groupsByName = {}; + for (final group in _groups) { + groupsByName.putIfAbsent(group.name, () => group); + } + final sortedGroups = groupsByName.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + final screenWidth = MediaQuery.sizeOf(context).width; + final searchExpandedWidth = (screenWidth * 0.52).clamp( + 97.0, + double.infinity, + ); // allow expansion up to 52% of screen width, but not less than the collapsed width + final searchCollapsedWidth = (screenWidth * 0.22).clamp( + 97.0, + 120.0, + ); //two 48px icon buttons + 1px divider + return Column( children: [ Padding( padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: hintText, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_searchQuery.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() { - _searchQuery = ''; - }); - }, + child: Row( + children: [ + Expanded( + child: _buildGroupButton( + context, + connector, + viewState, + contacts, + sortedGroups, + ), + ), + const SizedBox(width: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: viewState.contactsSearchExpanded + ? searchExpandedWidth + : searchCollapsedWidth, + height: 48, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outline, ), - _buildFilterButton(context, connector), - ], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: viewState.contactsSearchExpanded + ? TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setContactsSearchText(value); + }, + ); + }, + ) + : const SizedBox.shrink(), + ), + SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: () { + if (viewState.contactsSearchExpanded) { + _collapseContactsSearch(viewState); + return; + } + viewState.setContactsSearchExpanded(true); + }, + icon: Icon( + viewState.contactsSearchExpanded + ? Icons.close + : Icons.search, + ), + ), + ), + Container( + width: 1, + height: 24, + color: Theme.of(context).colorScheme.outlineVariant, + ), + SizedBox( + width: 48, + height: 48, + child: _buildFilterButton(context, viewState), + ), + ], + ), + ), ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 300), () { - if (!mounted) return; - setState(() { - _searchQuery = value.toLowerCase(); - }); - }); - }, + ], ), ), Expanded( - child: filteredAndSorted.isEmpty && filteredGroups.isEmpty + child: filteredAndSorted.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -514,7 +785,7 @@ class _ContactsScreenState extends State Icon(Icons.search_off, size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( - _showUnreadOnly + viewState.contactsShowUnreadOnly ? context.l10n.contacts_noUnreadContacts : context.l10n.contacts_noContactsFound, style: TextStyle(fontSize: 16, color: Colors.grey[600]), @@ -525,14 +796,9 @@ class _ContactsScreenState extends State : RefreshIndicator( onRefresh: () => connector.getContacts(), child: ListView.builder( - itemCount: filteredGroups.length + filteredAndSorted.length, + itemCount: filteredAndSorted.length, itemBuilder: (context, index) { - if (index < filteredGroups.length) { - final group = filteredGroups[index]; - return _buildGroupTile(context, group, contacts); - } - final contact = - filteredAndSorted[index - filteredGroups.length]; + final contact = filteredAndSorted[index]; final unreadCount = connector.getUnreadCountForContact( contact, ); @@ -553,55 +819,26 @@ class _ContactsScreenState extends State ); } - List _filterAndSortGroups( - List groups, - List contacts, - ) { - final query = _searchQuery.trim().toLowerCase(); - final contactsByKey = {}; - for (final contact in contacts) { - contactsByKey[contact.publicKeyHex] = contact; - } - - final filtered = groups - .where((group) { - if (query.isEmpty) return true; - if (group.name.toLowerCase().contains(query)) return true; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && matchesContactQuery(contact, query)) { - return true; - } - } - return false; - }) - .where((group) { - if (_typeFilter == ContactTypeFilter.all) return true; - // Groups don't have a favorite flag, so hide them under favorites filter - if (_typeFilter == ContactTypeFilter.favorites) return false; - for (final key in group.memberKeys) { - final contact = contactsByKey[key]; - if (contact != null && _matchesTypeFilter(contact)) return true; - } - return false; - }) - .toList(); - - filtered.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - return filtered; - } - List _filterAndSortContacts( List contacts, MeshCoreConnector connector, + UiViewStateService viewState, ) { var filtered = contacts.where((contact) { - if (_searchQuery.isEmpty) return true; - return matchesContactQuery(contact, _searchQuery); + if (viewState.contactsSearchText.isEmpty) return true; + return matchesContactQuery(contact, viewState.contactsSearchText); }).toList(); + final selectedGroup = _selectedGroupForName( + viewState.contactsSelectedGroupName, + ); + if (selectedGroup != null) { + final memberKeys = selectedGroup.memberKeys.toSet(); + filtered = filtered + .where((contact) => memberKeys.contains(contact.publicKeyHex)) + .toList(); + } + // Filter out own node from the list if (connector.selfPublicKey != null) { final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!); @@ -610,17 +847,22 @@ class _ContactsScreenState extends State }).toList(); } - if (_typeFilter != ContactTypeFilter.all) { - filtered = filtered.where(_matchesTypeFilter).toList(); + if (viewState.contactsTypeFilter != ContactTypeFilter.all) { + filtered = filtered + .where( + (contact) => + _matchesTypeFilter(contact, viewState.contactsTypeFilter), + ) + .toList(); } - if (_showUnreadOnly) { + if (viewState.contactsShowUnreadOnly) { filtered = filtered.where((contact) { return connector.getUnreadCountForContact(contact) > 0; }).toList(); } - switch (_sortOption) { + switch (viewState.contactsSortOption) { case ContactSortOption.lastSeen: filtered.sort( (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), @@ -649,8 +891,8 @@ class _ContactsScreenState extends State return filtered; } - bool _matchesTypeFilter(Contact contact) { - switch (_typeFilter) { + bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) { + switch (typeFilter) { case ContactTypeFilter.all: return true; case ContactTypeFilter.favorites: @@ -671,57 +913,6 @@ class _ContactsScreenState extends State : contact.lastSeen; } - Widget _buildGroupTile( - BuildContext context, - ContactGroup group, - List contacts, - ) { - final memberContacts = _resolveGroupContacts(group, contacts); - final subtitle = _formatGroupMembers(context, memberContacts); - return ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.teal, - child: Icon(Icons.group, color: Colors.white, size: 20), - ), - title: Text(group.name), - subtitle: Text(subtitle), - trailing: Text( - memberContacts.length.toString(), - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - onTap: () => _showGroupOptions(context, group, contacts), - onLongPress: () => _showGroupOptions(context, group, contacts), - ); - } - - List _resolveGroupContacts( - ContactGroup group, - List contacts, - ) { - final byKey = {}; - for (final contact in contacts) { - byKey[contact.publicKeyHex] = contact; - } - final resolved = []; - for (final key in group.memberKeys) { - final contact = byKey[key]; - if (contact != null) { - resolved.add(contact); - } - } - resolved.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - return resolved; - } - - String _formatGroupMembers(BuildContext context, List members) { - if (members.isEmpty) return context.l10n.contacts_noMembers; - final names = members.map((c) => c.name).toList(); - if (names.length <= 2) return names.join(', '); - return '${names.take(2).join(', ')} +${names.length - 2}'; - } - void _openChat(BuildContext context, Contact contact) { // Check if this is a repeater if (contact.type == advTypeRepeater) { @@ -799,58 +990,11 @@ class _ContactsScreenState extends State ); } - void _showGroupOptions( - BuildContext context, - ContactGroup group, - List contacts, - ) { - final members = _resolveGroupContacts(group, contacts); - showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit), - title: Text(context.l10n.contacts_editGroup), - onTap: () { - Navigator.pop(sheetContext); - _showGroupEditor(context, contacts, group: group); - }, - ), - ListTile( - leading: const Icon(Icons.delete, color: Colors.red), - title: Text( - context.l10n.contacts_deleteGroup, - style: const TextStyle(color: Colors.red), - ), - onTap: () { - Navigator.pop(sheetContext); - _confirmDeleteGroup(context, group); - }, - ), - if (members.isNotEmpty) const Divider(), - ...members.map((member) { - return ListTile( - leading: const Icon(Icons.person), - title: Text(member.name), - subtitle: Text(member.typeLabel), - onTap: () { - Navigator.pop(sheetContext); - _openChat(context, member); - }, - ); - }), - ], - ), - ), - ), - ); - } - void _confirmDeleteGroup(BuildContext context, ContactGroup group) { + if (!_hasGroupStoreScope(context.read())) { + _showGroupsUnavailableMessage(context); + return; + } showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -866,6 +1010,7 @@ class _ContactsScreenState extends State Navigator.pop(dialogContext); setState(() { _groups.removeWhere((g) => g.name == group.name); + _ensureValidSelectedGroup(); }); await _saveGroups(); }, @@ -884,6 +1029,10 @@ class _ContactsScreenState extends State List contacts, { ContactGroup? group, }) { + if (!_hasGroupStoreScope(context.read())) { + _showGroupsUnavailableMessage(context); + return; + } final isEditing = group != null; final nameController = TextEditingController(text: group?.name ?? ''); final selectedKeys = {...group?.memberKeys ?? []}; @@ -910,64 +1059,70 @@ class _ContactsScreenState extends State ), content: SizedBox( width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: InputDecoration( - labelText: context.l10n.contacts_groupName, - border: const OutlineInputBorder(), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: context.l10n.contacts_groupName, + border: const OutlineInputBorder(), + ), ), - ), - const SizedBox(height: 12), - TextField( - decoration: InputDecoration( - hintText: context.l10n.contacts_filterContacts, - prefixIcon: const Icon(Icons.search), - border: const OutlineInputBorder(), - isDense: true, + const SizedBox(height: 12), + TextField( + decoration: InputDecoration( + hintText: context.l10n.contacts_filterContacts, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: (value) { + setDialogState(() { + filterQuery = value.toLowerCase(); + }); + }, ), - onChanged: (value) { - setDialogState(() { - filterQuery = value.toLowerCase(); - }); - }, - ), - const SizedBox(height: 12), - SizedBox( - height: 240, - child: filteredContacts.isEmpty - ? Center( - child: Text( - context.l10n.contacts_noContactsMatchFilter, + const SizedBox(height: 12), + Expanded( + child: filteredContacts.isEmpty + ? Center( + child: Text( + context.l10n.contacts_noContactsMatchFilter, + ), + ) + : ListView.builder( + itemCount: filteredContacts.length, + itemBuilder: (context, index) { + final contact = filteredContacts[index]; + final isSelected = selectedKeys.contains( + contact.publicKeyHex, + ); + return CheckboxListTile( + value: isSelected, + title: Text(contact.name), + subtitle: Text(contact.typeLabel), + onChanged: (value) { + setDialogState(() { + if (value == true) { + selectedKeys.add(contact.publicKeyHex); + } else { + selectedKeys.remove( + contact.publicKeyHex, + ); + } + }); + }, + ); + }, ), - ) - : ListView.builder( - itemCount: filteredContacts.length, - itemBuilder: (context, index) { - final contact = filteredContacts[index]; - final isSelected = selectedKeys.contains( - contact.publicKeyHex, - ); - return CheckboxListTile( - value: isSelected, - title: Text(contact.name), - subtitle: Text(contact.typeLabel), - onChanged: (value) { - setDialogState(() { - if (value == true) { - selectedKeys.add(contact.publicKeyHex); - } else { - selectedKeys.remove(contact.publicKeyHex); - } - }); - }, - ); - }, - ), - ), - ], + ), + ], + ), ), ), actions: [ @@ -986,6 +1141,15 @@ class _ContactsScreenState extends State ); return; } + if (name.toLowerCase() == + contactsAllGroupsValue.toLowerCase()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.contacts_groupNameReserved), + ), + ); + return; + } final exists = _groups.any((g) { if (isEditing && g.name == group.name) return false; return g.name.toLowerCase() == name.toLowerCase(); @@ -1001,15 +1165,21 @@ class _ContactsScreenState extends State return; } setState(() { + final viewState = context.read(); if (isEditing) { final index = _groups.indexWhere( (g) => g.name == group.name, ); if (index != -1) { + final wasSelected = + viewState.contactsSelectedGroupName == group.name; _groups[index] = ContactGroup( name: name, memberKeys: selectedKeys.toList(), ); + if (wasSelected) { + viewState.setContactsSelectedGroupName(name); + } } } else { _groups.add( @@ -1018,7 +1188,9 @@ class _ContactsScreenState extends State memberKeys: selectedKeys.toList(), ), ); + viewState.setContactsSelectedGroupName(name); } + _ensureValidSelectedGroup(); }); await _saveGroups(); if (dialogContext.mounted) { diff --git a/lib/services/chat_text_scale_service.dart b/lib/services/chat_text_scale_service.dart index 21d6a5f..205c258 100644 --- a/lib/services/chat_text_scale_service.dart +++ b/lib/services/chat_text_scale_service.dart @@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier { void _commitScale() { _saveTimer?.cancel(); - PrefsManager.instance.setDouble(_prefKey, _scale); + unawaited(PrefsManager.instance.setDouble(_prefKey, _scale)); } double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); diff --git a/lib/services/ui_view_state_service.dart b/lib/services/ui_view_state_service.dart new file mode 100644 index 0000000..7f2a03a --- /dev/null +++ b/lib/services/ui_view_state_service.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../storage/prefs_manager.dart'; +import '../utils/contact_search.dart'; + +const String contactsAllGroupsValue = '__all__'; + +enum ChannelSortOption { manual, name, latestMessages, unread } + +class UiViewStateService extends ChangeNotifier { + static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group'; + static const _keyContactsSortOption = 'ui_contacts_sort_option'; + static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only'; + static const _keyContactsTypeFilter = 'ui_contacts_type_filter'; + static const _keyChannelsSortOption = 'ui_channels_sort_option'; + static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index'; + + String _contactsSelectedGroupName = contactsAllGroupsValue; + String _contactsSearchText = ''; + bool _contactsSearchExpanded = false; + ContactSortOption _contactsSortOption = ContactSortOption.lastSeen; + bool _contactsShowUnreadOnly = false; + ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all; + + String _channelsSearchText = ''; + ChannelSortOption _channelsSortOption = ChannelSortOption.manual; + + String get contactsSelectedGroupName => _contactsSelectedGroupName; + String get contactsSearchText => _contactsSearchText; + bool get contactsSearchExpanded => _contactsSearchExpanded; + ContactSortOption get contactsSortOption => _contactsSortOption; + bool get contactsShowUnreadOnly => _contactsShowUnreadOnly; + ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter; + String get channelsSearchText => _channelsSearchText; + ChannelSortOption get channelsSortOption => _channelsSortOption; + + Future initialize() async { + final prefs = PrefsManager.instance; + + final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName); + if (selectedGroupName != null && selectedGroupName.isNotEmpty) { + _contactsSelectedGroupName = selectedGroupName; + } + + final sortStr = prefs.getString(_keyContactsSortOption); + if (sortStr != null) { + _contactsSortOption = ContactSortOption.values.firstWhere( + (e) => e.name == sortStr, + orElse: () => ContactSortOption.lastSeen, + ); + } + + _contactsShowUnreadOnly = + prefs.getBool(_keyContactsShowUnreadOnly) ?? false; + + final typeStr = prefs.getString(_keyContactsTypeFilter); + if (typeStr != null) { + _contactsTypeFilter = ContactTypeFilter.values.firstWhere( + (e) => e.name == typeStr, + orElse: () => ContactTypeFilter.all, + ); + } + + final channelSortStr = prefs.getString(_keyChannelsSortOption); + if (channelSortStr != null) { + _channelsSortOption = ChannelSortOption.values.firstWhere( + (e) => e.name == channelSortStr, + orElse: () => ChannelSortOption.manual, + ); + return; + } + + // Backward compatibility for old persisted index format. + switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) { + case 0: + _channelsSortOption = ChannelSortOption.manual; + break; + case 1: + _channelsSortOption = ChannelSortOption.name; + break; + case 2: + _channelsSortOption = ChannelSortOption.latestMessages; + break; + case 3: + _channelsSortOption = ChannelSortOption.unread; + break; + default: + _channelsSortOption = ChannelSortOption.manual; + } + } + + void setContactsSelectedGroupName(String value) { + if (_contactsSelectedGroupName == value) return; + _contactsSelectedGroupName = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsSelectedGroupName, value), + ); + } + + void setContactsSearchText(String value) { + if (_contactsSearchText == value) return; + _contactsSearchText = value; + notifyListeners(); + } + + void setContactsSearchExpanded(bool value) { + if (_contactsSearchExpanded == value) return; + _contactsSearchExpanded = value; + notifyListeners(); + } + + void setContactsSortOption(ContactSortOption value) { + if (_contactsSortOption == value) return; + _contactsSortOption = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsSortOption, value.name), + ); + } + + void setContactsShowUnreadOnly(bool value) { + if (_contactsShowUnreadOnly == value) return; + _contactsShowUnreadOnly = value; + notifyListeners(); + unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value)); + } + + void setContactsTypeFilter(ContactTypeFilter value) { + if (_contactsTypeFilter == value) return; + _contactsTypeFilter = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyContactsTypeFilter, value.name), + ); + } + + void setChannelsSearchText(String value) { + if (_channelsSearchText == value) return; + _channelsSearchText = value; + notifyListeners(); + } + + void setChannelsSortOption(ChannelSortOption value) { + if (_channelsSortOption == value) return; + _channelsSortOption = value; + notifyListeners(); + unawaited( + PrefsManager.instance.setString(_keyChannelsSortOption, value.name), + ); + } +} diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index 1f05fdc..849172a 100644 --- a/lib/utils/contact_search.dart +++ b/lib/utils/contact_search.dart @@ -1,5 +1,9 @@ import '../models/contact.dart'; +enum ContactSortOption { lastSeen, recentMessages, name } + +enum ContactTypeFilter { all, favorites, users, repeaters, rooms } + bool matchesContactQuery(Contact contact, String query) { final normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.isEmpty) return true; diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index ee6fcd4..8b2874b 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -1,12 +1,9 @@ import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import '../utils/contact_search.dart'; -enum ContactSortOption { lastSeen, recentMessages, name } - -enum ContactTypeFilter { all, favorites, users, repeaters, rooms } - -class SortFilterMenuOption { - final int value; +class SortFilterMenuOption { + final T value; final String label; final bool? checked; @@ -17,16 +14,16 @@ class SortFilterMenuOption { }); } -class SortFilterMenuSection { +class SortFilterMenuSection { final String title; - final List options; + final List> options; const SortFilterMenuSection({required this.title, required this.options}); } -class SortFilterMenu extends StatelessWidget { - final List sections; - final ValueChanged onSelected; +class SortFilterMenu extends StatelessWidget { + final List> sections; + final ValueChanged onSelected; final String tooltip; final Widget icon; @@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return PopupMenuButton( + return PopupMenuButton( icon: icon, tooltip: tooltip, onSelected: onSelected, @@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget { final visibleSections = sections .where((section) => section.options.isNotEmpty) .toList(); - final entries = >[]; + final entries = >[]; for (int i = 0; i < visibleSections.length; i++) { final section = visibleSections[i]; entries.add( - PopupMenuItem( + PopupMenuItem( enabled: false, child: Text(section.title, style: labelStyle), ), @@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget { for (final option in section.options) { if (option.checked == null) { entries.add( - PopupMenuItem( + PopupMenuItem( value: option.value, child: Text(option.label), ), ); } else { entries.add( - CheckedPopupMenuItem( + CheckedPopupMenuItem( value: option.value, checked: option.checked ?? false, child: Text(option.label), @@ -99,7 +96,6 @@ const int _actionFilterUsers = 6; const int _actionFilterRepeaters = 7; const int _actionFilterRooms = 8; const int _actionToggleUnreadOnly = 9; -const int _actionNewGroup = 10; class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; @@ -108,7 +104,6 @@ class ContactsFilterMenu extends StatelessWidget { final ValueChanged onSortChanged; final ValueChanged onTypeFilterChanged; final ValueChanged onUnreadOnlyChanged; - final VoidCallback onNewGroup; const ContactsFilterMenu({ super.key, @@ -118,7 +113,6 @@ class ContactsFilterMenu extends StatelessWidget { required this.onSortChanged, required this.onTypeFilterChanged, required this.onUnreadOnlyChanged, - required this.onNewGroup, }); @override @@ -180,10 +174,6 @@ class ContactsFilterMenu extends StatelessWidget { label: l10n.listFilter_unreadOnly, checked: showUnreadOnly, ), - SortFilterMenuOption( - value: _actionNewGroup, - label: l10n.listFilter_newGroup, - ), ], ), ], @@ -216,9 +206,6 @@ class ContactsFilterMenu extends StatelessWidget { case _actionToggleUnreadOnly: onUnreadOnlyChanged(!showUnreadOnly); break; - case _actionNewGroup: - onNewGroup(); - break; } }, ); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e..4084d9b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ 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 @@ -20,6 +21,7 @@ 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")) From 2ee2358eccebb18f5e34b062d95b26f9e73eb8fe Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 16:56:11 -0700 Subject: [PATCH 16/75] 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. --- lib/connector/meshcore_connector.dart | 36 ++- lib/main.dart | 8 + lib/models/delivery_observation.dart | 43 ++++ lib/services/message_retry_service.dart | 54 +++-- lib/services/storage_service.dart | 50 ++++ lib/services/timeout_prediction_service.dart | 224 ++++++++++++++++++ pubspec.yaml | 2 + test/services/ml_algo_sanity_test.dart | 122 ++++++++++ .../timeout_prediction_service_test.dart | 164 +++++++++++++ 9 files changed, 683 insertions(+), 20 deletions(-) create mode 100644 lib/models/delivery_observation.dart create mode 100644 lib/services/timeout_prediction_service.dart create mode 100644 test/services/ml_algo_sanity_test.dart create mode 100644 test/services/timeout_prediction_service_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7cf32ef..d05a8f9 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,8 @@ class MeshCoreConnector extends ChangeNotifier { bool _isLoadingContacts = false; bool _isLoadingChannels = false; bool _hasLoadedChannels = false; + TimeoutPredictionService? _timeoutPredictionService; + DateTime _lastRxTime = DateTime.now(); bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; @@ -668,6 +671,7 @@ class MeshCoreConnector extends ChangeNotifier { BleDebugLogService? bleDebugLogService, AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, + TimeoutPredictionService? timeoutPredictionService, }) { _retryService = retryService; _pathHistoryService = pathHistoryService; @@ -675,6 +679,7 @@ class MeshCoreConnector extends ChangeNotifier { _bleDebugLogService = bleDebugLogService; _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; + _timeoutPredictionService = timeoutPredictionService; _usbManager.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService); @@ -689,13 +694,23 @@ 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, + ); + }, ); } @@ -2498,6 +2513,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleFrame(List data) { if (data.isEmpty) return; + _lastRxTime = DateTime.now(); final frame = Uint8List.fromList(data); _receivedFramesController.add(frame); @@ -2876,7 +2892,21 @@ 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}) { + int calculateTimeout({ + required int pathLength, + int messageBytes = 100, + String? contactKey, + }) { + // Try ML-based prediction first + final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; + final mlTimeout = _timeoutPredictionService?.predictTimeout( + contactKey: contactKey, + pathLength: pathLength, + messageBytes: messageBytes, + secondsSinceLastRx: secSinceRx, + ); + if (mlTimeout != null) return mlTimeout; + // If we have radio settings, use them for accurate calculation if (_currentFreqHz != null && _currentBwHz != null && diff --git a/lib/main.dart b/lib/main.dart index 9e53e21..72909e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'services/chat_text_scale_service.dart'; +import 'services/timeout_prediction_service.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -39,6 +40,7 @@ void main() async { final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); final chatTextScaleService = ChatTextScaleService(); + final timeoutPredictionService = TimeoutPredictionService(storage); // Load settings await appSettingsService.loadSettings(); @@ -56,6 +58,7 @@ void main() async { _registerThirdPartyLicenses(); await chatTextScaleService.initialize(); + await timeoutPredictionService.initialize(); // Wire up connector with services connector.initialize( @@ -65,6 +68,7 @@ void main() async { bleDebugLogService: bleDebugLogService, appDebugLogService: appDebugLogService, backgroundService: backgroundService, + timeoutPredictionService: timeoutPredictionService, ); await connector.loadContactCache(); @@ -86,6 +90,7 @@ void main() async { appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, chatTextScaleService: chatTextScaleService, + timeoutPredictionService: timeoutPredictionService, ), ); } @@ -121,6 +126,7 @@ class MeshCoreApp extends StatelessWidget { final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; final ChatTextScaleService chatTextScaleService; + final TimeoutPredictionService timeoutPredictionService; const MeshCoreApp({ super.key, @@ -133,6 +139,7 @@ class MeshCoreApp extends StatelessWidget { required this.appDebugLogService, required this.mapTileCacheService, required this.chatTextScaleService, + required this.timeoutPredictionService, }); @override @@ -148,6 +155,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: chatTextScaleService), 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/services/message_retry_service.dart b/lib/services/message_retry_service.dart index db4475f..d94b763 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -58,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(); @@ -73,12 +74,14 @@ 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; @@ -91,6 +94,7 @@ class MessageRetryService extends ChangeNotifier { _appSettingsService = appSettingsService; _debugLogService = debugLogService; _recordPathResultCallback = recordPathResultCallback; + _onDeliveryObservedCallback = onDeliveryObservedCallback; } /// Compute expected ACK hash using same algorithm as firmware: @@ -423,25 +427,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( @@ -738,6 +750,14 @@ class MessageRetryService extends ChangeNotifier { true, tripTimeMs, ); + if (_onDeliveryObservedCallback != null && tripTimeMs > 0) { + _onDeliveryObservedCallback!( + contact.publicKeyHex, + message.pathLength ?? 0, + message.text.length, + tripTimeMs, + ); + } _onMessageResolved(matchedMessageId, contact.publicKeyHex); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ce0c4f1..c591f64 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,8 @@ 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'; + static const String _timeoutModelKey = 'timeout_ml_model'; Future savePathHistory( String contactPubKeyHex, @@ -122,4 +125,51 @@ 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); + } + + Future saveTimeoutModel(String modelJson) async { + final prefs = PrefsManager.instance; + await prefs.setString(_timeoutModelKey, modelJson); + } + + Future loadTimeoutModel() async { + final prefs = PrefsManager.instance; + return prefs.getString(_timeoutModelKey); + } + + Future clearTimeoutModel() async { + final prefs = PrefsManager.instance; + await prefs.remove(_timeoutModelKey); + } } diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart new file mode 100644 index 0000000..21e229e --- /dev/null +++ b/lib/services/timeout_prediction_service.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'dart:math'; +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; + double _sumSq = 0; + + void add(double ms) { + count++; + _sum += ms; + _sumSq += ms * ms; + } + + double get mean => _sum / count; + double get stdDev => sqrt((_sumSq / count) - (mean * mean)); +} + +class TimeoutPredictionService extends ChangeNotifier { + final StorageService? _storage; + + static const int minObservations = 10; + static const int maxObservations = 100; + static const int _retrainInterval = 5; + static const double _safetyMargin = 1.5; + static const int _minTimeoutMs = 2000; + static const int _maxTimeoutMs = 120000; + static const int _minContactObservations = 10; + + List _observations = []; + LinearRegressor? _model; + List _activeFeatures = []; + int _observationsSinceLastTrain = 0; + final Map _contactStats = {}; + + 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(); + } + + _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; + } + } + + final timeout = + (predictedMs * _safetyMargin).ceil().clamp(_minTimeoutMs, _maxTimeoutMs); + 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)', + ); + + final modelJson = jsonEncode(_model!.toJson()); + _storage?.saveTimeoutModel(modelJson); + notifyListeners(); + } catch (e) { + debugPrint('TimeoutPrediction: training failed: $e'); + } + } + + void _rebuildContactStats() { + _contactStats.clear(); + for (final obs in _observations) { + _contactStats.putIfAbsent(obs.contactKey, () => _ContactStats()); + _contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble()); + } + } +} 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..e4f980e --- /dev/null +++ b/test/services/ml_algo_sanity_test.dart @@ -0,0 +1,122 @@ +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..46dc5df --- /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 within the clamp range + expect(direct0, greaterThanOrEqualTo(2000)); + expect(direct4, lessThanOrEqualTo(120000)); + + // 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(), + ); +} From b336aedbc58e2646149c071509ac94264744c8b6 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 17:32:08 -0700 Subject: [PATCH 17/75] 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 --- lib/connector/meshcore_connector.dart | 107 ++++++++------ lib/services/message_retry_service.dart | 18 ++- lib/services/storage_service.dart | 21 +-- lib/services/timeout_prediction_service.dart | 41 +++--- test/services/ml_algo_sanity_test.dart | 136 +++++++++++------- .../timeout_prediction_service_test.dart | 6 +- 6 files changed, 187 insertions(+), 142 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d05a8f9..33e5c48 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -168,6 +168,8 @@ class MeshCoreConnector extends ChangeNotifier { 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; @@ -694,23 +696,28 @@ class MeshCoreConnector extends ChangeNotifier { updateMessageCallback: _updateMessage, clearContactPathCallback: clearContactPath, setContactPathCallback: setContactPath, - calculateTimeoutCallback: (pathLength, messageBytes, {String? contactKey}) => - calculateTimeout(pathLength: pathLength, messageBytes: messageBytes, contactKey: contactKey), + 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, - ); - }, + onDeliveryObservedCallback: + (contactKey, pathLength, messageBytes, tripTimeMs) { + final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; + _timeoutPredictionService?.recordObservation( + contactKey: contactKey, + pathLength: pathLength, + messageBytes: messageBytes, + tripTimeMs: tripTimeMs, + secondsSinceLastRx: secSinceRx, + ); + }, ); } @@ -2890,14 +2897,54 @@ class MeshCoreConnector extends ChangeNotifier { } } - /// Calculate timeout for a message based on radio settings and path length - /// Returns timeout in milliseconds, considering number of hops + /// 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 calculateLoRaAirtime( + payloadBytes: messageBytes, + spreadingFactor: _currentSf!, + bandwidthHz: _currentBwHz!, + codingRate: cr, + lowDataRateOptimize: _currentSf! >= 11, + ); + } + return 50; // fallback: ~SF7/BW125 for 100 bytes + } + + /// Physics-based worst-case timeout (ceiling). + int _physicsMaxTimeout(int pathLength, int airtime) { + if (pathLength < 0) { + return 500 + (16 * airtime); + } else { + 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, }) { - // Try ML-based prediction first + 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, @@ -2905,35 +2952,11 @@ class MeshCoreConnector extends ChangeNotifier { messageBytes: messageBytes, secondsSinceLastRx: secSinceRx, ); - if (mlTimeout != null) return mlTimeout; - - // If we have radio settings, use them for accurate calculation - 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, - ); + if (mlTimeout != null) { + return mlTimeout.clamp(physicsMin, physicsMax); } - // Fallback: Conservative estimates based on typical settings - // Assume SF7, BW125, which gives ~50ms airtime for 100 bytes - const estimatedAirtime = 50; - - if (pathLength < 0) { - // Flood mode: Base delay + 16× airtime - return 500 + (16 * estimatedAirtime); - } else { - // Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) - return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1)); - } + return physicsMax; } void _handleContact(Uint8List frame, {bool isContact = true}) { diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index d94b763..b66ba51 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -74,14 +74,20 @@ class MessageRetryService extends ChangeNotifier { required Function(Message) updateMessageCallback, Function(Contact)? clearContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback, - Function(int pathLength, int messageBytes, {String? contactKey})? 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, + Function( + String contactKey, + int pathLength, + int messageBytes, + int tripTimeMs, + )? + onDeliveryObservedCallback, }) { _sendMessageCallback = sendMessageCallback; _addMessageCallback = addMessageCallback; @@ -750,10 +756,12 @@ class MessageRetryService extends ChangeNotifier { true, tripTimeMs, ); - if (_onDeliveryObservedCallback != null && tripTimeMs > 0) { + if (_onDeliveryObservedCallback != null && + tripTimeMs > 0 && + message.pathLength != null) { _onDeliveryObservedCallback!( contact.publicKeyHex, - message.pathLength ?? 0, + message.pathLength!, message.text.length, tripTimeMs, ); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index c591f64..a86c1f6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -8,7 +8,6 @@ class StorageService { static const String _pendingMessagesKey = 'pending_messages'; static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _deliveryObservationsKey = 'delivery_observations'; - static const String _timeoutModelKey = 'timeout_ml_model'; Future savePathHistory( String contactPubKeyHex, @@ -143,10 +142,7 @@ class StorageService { try { final list = jsonDecode(jsonStr) as List; return list - .map( - (e) => - DeliveryObservation.fromJson(e as Map), - ) + .map((e) => DeliveryObservation.fromJson(e as Map)) .toList(); } catch (e) { return []; @@ -157,19 +153,4 @@ class StorageService { final prefs = PrefsManager.instance; await prefs.remove(_deliveryObservationsKey); } - - Future saveTimeoutModel(String modelJson) async { - final prefs = PrefsManager.instance; - await prefs.setString(_timeoutModelKey, modelJson); - } - - Future loadTimeoutModel() async { - final prefs = PrefsManager.instance; - return prefs.getString(_timeoutModelKey); - } - - Future clearTimeoutModel() async { - final prefs = PrefsManager.instance; - await prefs.remove(_timeoutModelKey); - } } diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart index 21e229e..1f3d6dd 100644 --- a/lib/services/timeout_prediction_service.dart +++ b/lib/services/timeout_prediction_service.dart @@ -1,5 +1,4 @@ -import 'dart:convert'; -import 'dart:math'; +import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:ml_algo/ml_algo.dart'; import 'package:ml_dataframe/ml_dataframe.dart'; @@ -9,16 +8,13 @@ import 'storage_service.dart'; class _ContactStats { int count = 0; double _sum = 0; - double _sumSq = 0; void add(double ms) { count++; _sum += ms; - _sumSq += ms * ms; } double get mean => _sum / count; - double get stdDev => sqrt((_sumSq / count) - (mean * mean)); } class TimeoutPredictionService extends ChangeNotifier { @@ -27,9 +23,10 @@ class TimeoutPredictionService extends ChangeNotifier { 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 _minTimeoutMs = 2000; - static const int _maxTimeoutMs = 120000; static const int _minContactObservations = 10; List _observations = []; @@ -37,6 +34,7 @@ class TimeoutPredictionService extends ChangeNotifier { List _activeFeatures = []; int _observationsSinceLastTrain = 0; final Map _contactStats = {}; + Timer? _persistTimer; TimeoutPredictionService(StorageService storage) : _storage = storage; TimeoutPredictionService.noStorage() : _storage = null; @@ -89,7 +87,10 @@ class TimeoutPredictionService extends ChangeNotifier { _trainModel(); } - _storage?.saveDeliveryObservations(_observations); + _persistTimer?.cancel(); + _persistTimer = Timer(const Duration(seconds: 2), () { + _storage?.saveDeliveryObservations(_observations); + }); debugPrint( 'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops ' '(${_observations.length} total)', @@ -123,7 +124,9 @@ class TimeoutPredictionService extends ChangeNotifier { final prediction = _model!.predict(features); final rawValue = prediction.rows.first.first; - var predictedMs = (rawValue is double) ? rawValue : (rawValue as num).toDouble(); + var predictedMs = (rawValue is double) + ? rawValue + : (rawValue as num).toDouble(); debugPrint( 'TimeoutPrediction: raw prediction=$predictedMs for ' @@ -142,8 +145,8 @@ class TimeoutPredictionService extends ChangeNotifier { } } - final timeout = - (predictedMs * _safetyMargin).ceil().clamp(_minTimeoutMs, _maxTimeoutMs); + // 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)', @@ -174,7 +177,9 @@ class TimeoutPredictionService extends ChangeNotifier { } if (_activeFeatures.isEmpty) { - debugPrint('TimeoutPrediction: no features with variance, skipping training'); + debugPrint( + 'TimeoutPrediction: no features with variance, skipping training', + ); return; } @@ -190,25 +195,19 @@ class TimeoutPredictionService extends ChangeNotifier { return row; }); - final data = DataFrame( - [header, ...rows], - headerExists: true, - ); + 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) / + 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)', ); - - final modelJson = jsonEncode(_model!.toJson()); - _storage?.saveTimeoutModel(modelJson); - notifyListeners(); } catch (e) { debugPrint('TimeoutPrediction: training failed: $e'); } diff --git a/test/services/ml_algo_sanity_test.dart b/test/services/ml_algo_sanity_test.dart index e4f980e..427a8a6 100644 --- a/test/services/ml_algo_sanity_test.dart +++ b/test/services/ml_algo_sanity_test.dart @@ -6,18 +6,22 @@ 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']); + 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}'); @@ -25,7 +29,9 @@ void main() { final model = LinearRegressor(data, 'y'); final testDf = DataFrame( - [[25.0]], + [ + [25.0], + ], headerExists: false, header: ['x'], ); @@ -38,45 +44,63 @@ void main() { 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 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]], + [ + [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)'); + 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 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]], + [ + [hops, 50.0], + ], headerExists: false, header: ['pathLength', 'messageBytes'], ); @@ -87,20 +111,28 @@ void main() { 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 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'); @@ -116,7 +148,9 @@ void main() { 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'); + 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 index 46dc5df..dbd852d 100644 --- a/test/services/timeout_prediction_service_test.dart +++ b/test/services/timeout_prediction_service_test.dart @@ -64,9 +64,9 @@ void main() { expect(direct4!, greaterThan(direct2!)); expect(direct2, greaterThan(direct0!)); - // All should be within the clamp range - expect(direct0, greaterThanOrEqualTo(2000)); - expect(direct4, lessThanOrEqualTo(120000)); + // All should be positive + expect(direct0, greaterThan(0)); + expect(direct4, greaterThan(0)); // Print predictions for visibility debugPrint('Predictions (with 1.5x safety margin):'); From fffcff3b74896e9fe0dd64d02756fd94d7565062 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 17:39:01 -0700 Subject: [PATCH 18/75] fix: cancel persist timer on dispose to prevent post-dispose writes --- lib/services/timeout_prediction_service.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/services/timeout_prediction_service.dart b/lib/services/timeout_prediction_service.dart index 1f3d6dd..d92ca64 100644 --- a/lib/services/timeout_prediction_service.dart +++ b/lib/services/timeout_prediction_service.dart @@ -213,6 +213,12 @@ class TimeoutPredictionService extends ChangeNotifier { } } + @override + void dispose() { + _persistTimer?.cancel(); + super.dispose(); + } + void _rebuildContactStats() { _contactStats.clear(); for (final obs in _observations) { From 06a906f4f71ec8e33aaebb26ee7242a7b9ff1039 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 17:51:24 -0700 Subject: [PATCH 19/75] Enhance location handling and improve path trace functionality across screens --- lib/connector/meshcore_connector.dart | 16 +++- lib/models/contact.dart | 54 +++-------- lib/screens/channel_message_path_screen.dart | 5 +- lib/screens/chat_screen.dart | 2 +- lib/screens/contacts_screen.dart | 17 ++-- lib/screens/map_screen.dart | 11 +-- lib/screens/path_trace_map.dart | 99 +++++++++++++++----- lib/services/app_debug_log_service.dart | 17 ++-- lib/utils/app_logger.dart | 15 +-- lib/widgets/path_management_dialog.dart | 2 +- 10 files changed, 138 insertions(+), 100 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index dad5ed1..86484d8 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4753,8 +4753,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 && + longitude != 0 + ? latitude + : existing.latitude, + longitude: + hasLocation && + longitude != null && + longitude.abs() <= 180 && + longitude != 0 + ? longitude + : existing.longitude, name: hasName ? name : existing.name, path: Uint8List.fromList(path.reversed.toList()), pathLength: path.length, diff --git a/lib/models/contact.dart b/lib/models/contact.dart index cab58cb..858d712 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import '../connector/meshcore_protocol.dart'; @@ -65,7 +66,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 +119,7 @@ class Contact { } String get pathIdList { - final pathBytes = _pathBytesForDisplay; + final pathBytes = pathBytesForDisplay; if (pathBytes.isEmpty) return ''; final parts = []; final groupSize = pathHashSize; @@ -130,43 +141,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 +172,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; diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 32eadef..747c2bf 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -62,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), ), ), ), diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 6558ecd..5209b41 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -858,7 +858,7 @@ class _ChatScreenState extends State { builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPathTrace, path: Uint8List.fromList(pathBytes), - flipPathRound: true, + flipPathAround: true, targetContact: widget.contact, ), ), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 243c8c4..ed2e171 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1064,7 +1064,7 @@ class _ContactsScreenState extends State 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: () { @@ -1072,10 +1072,12 @@ class _ContactsScreenState extends State 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, ), ), ); @@ -1100,10 +1102,12 @@ class _ContactsScreenState extends State 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, ), ), ); @@ -1145,7 +1149,8 @@ class _ContactsScreenState extends State title: context.l10n.contacts_pathTraceTo( contact.name, ), - path: contact.traceRouteBytes ?? Uint8List(0), + path: contact.pathBytesForDisplay, + flipPathAround: true, targetContact: contact, ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 497c05f..df16a59 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -176,20 +176,13 @@ class _MapScreenState extends State { // 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 diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 6277886..d50a185 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -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 { ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; + Contact? target; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State { }); } - 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(context, listen: false); @@ -309,18 +305,20 @@ class _PathTraceMapScreenState extends State { // Compute endpoint position for the target contact. LatLng? targetPos; bool targetGuessed = false; - final target = widget.targetContact; + target = widget.targetContact; + if (target != null) { - if (target.hasLocation) { - targetPos = LatLng(target.latitude!, target.longitude!); - } else if (pathData.isNotEmpty) { + if (target?.hasLocation ?? false) { + targetPos = LatLng(target!.latitude!, target!.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 + final lastHop = widget.reversePathAround + ? widget.path.first + : widget.path.last; + + final peers = connector.allContacts .where( (c) => c.hasLocation && @@ -336,12 +334,35 @@ class _PathTraceMapScreenState extends State { 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 = (target!.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 = (target!.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 = (target!.publicKey[1] / 255.0) * 2 * pi; + targetPos = LatLng( + contact.latitude! + offsetDeg * cos(angle), + contact.longitude! + offsetDeg * sin(angle), + ); + targetGuessed = true; + targetGuessed = true; + } } } } @@ -350,7 +371,12 @@ class _PathTraceMapScreenState extends State { _points = []; _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!)); @@ -358,8 +384,14 @@ class _PathTraceMapScreenState extends State { final inferred = inferredPositions[hop]; if (inferred != null) _points.add(inferred); } + hopLastLast = hopLast; + hopLast = hop; + } + if (targetPos != null) { + if (target != null && target!.type == advTypeChat) { + _points.add(targetPos); + } } - if (targetPos != null) _points.add(targetPos); _polylines = _points.length > 1 ? [ Polyline( @@ -448,7 +480,7 @@ class _PathTraceMapScreenState extends State { ], ), ), - if (_hasData) _buildMapPathTrace(context, tileCache), + if (_hasData) _buildMapPathTrace(context, tileCache, target), if (_points.isEmpty && !_hasData && !_isLoading && @@ -477,17 +509,28 @@ class _PathTraceMapScreenState extends State { List _buildHopMarkers( List pathData, { required bool showLabels, + required Contact? target, }) { final markers = []; + 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, @@ -529,6 +572,8 @@ class _PathTraceMapScreenState extends State { ), ); } + hopLastLast = hopLast; + hopLast = hop; } final selfLat = context.read().selfLatitude; @@ -578,9 +623,9 @@ class _PathTraceMapScreenState extends State { // 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, @@ -716,6 +761,7 @@ class _PathTraceMapScreenState extends State { Widget _buildMapPathTrace( BuildContext context, MapTileCacheService tileCache, + Contact? target, ) { return FlutterMap( key: _mapKey, @@ -754,6 +800,7 @@ class _PathTraceMapScreenState extends State { markers: _buildHopMarkers( _traceData!.pathData, showLabels: _showNodeLabels, + target: target, ), ), ], diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart index c63e625..d31c3e5 100644 --- a/lib/services/app_debug_log_service.dart +++ b/lib/services/app_debug_log_service.dart @@ -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() { diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index e57261e..1f34a5e 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -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); } } } diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 0233b43..861241b 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -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, ), ), From 566e3aadf83d9124007014617cc5e36e8679ee50 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 17:59:48 -0700 Subject: [PATCH 20/75] fix: migrate filter menus to type-safe generics and harden popup dismissal - Move ContactSortOption/ContactTypeFilter enums to dedicated contact_filter_types.dart (re-exported from contact_search.dart) - Migrate ContactsFilterMenu and DiscoveryContactsFilterMenu to use sealed class action types with SortFilterMenu generics, replacing int action constants and switch statements - Guard _closeDropdownAndRun with ModalRoute.isCurrent check to prevent accidental dismissal of parent routes Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/screens/contacts_screen.dart | 7 +- lib/utils/contact_filter_types.dart | 3 + lib/utils/contact_search.dart | 4 +- lib/widgets/list_filter_widget.dart | 130 ++++++++++++---------------- 4 files changed, 66 insertions(+), 78 deletions(-) create mode 100644 lib/utils/contact_filter_types.dart diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index abb29fa..a6739e1 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -454,8 +454,11 @@ class _ContactsScreenState extends State } } - void _closeDropdownAndRun(BuildContext context, VoidCallback action) { - Navigator.of(context).pop(); + void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) { + final route = ModalRoute.of(popupContext); + if (route != null && route.isCurrent) { + Navigator.of(popupContext).pop(); + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; action(); diff --git a/lib/utils/contact_filter_types.dart b/lib/utils/contact_filter_types.dart new file mode 100644 index 0000000..08e07c2 --- /dev/null +++ b/lib/utils/contact_filter_types.dart @@ -0,0 +1,3 @@ +enum ContactSortOption { lastSeen, recentMessages, name } + +enum ContactTypeFilter { all, favorites, users, repeaters, rooms } diff --git a/lib/utils/contact_search.dart b/lib/utils/contact_search.dart index 849172a..7a82c53 100644 --- a/lib/utils/contact_search.dart +++ b/lib/utils/contact_search.dart @@ -1,8 +1,6 @@ import '../models/contact.dart'; -enum ContactSortOption { lastSeen, recentMessages, name } - -enum ContactTypeFilter { all, favorites, users, repeaters, rooms } +export 'contact_filter_types.dart'; bool matchesContactQuery(Contact contact, String query) { final normalizedQuery = query.trim().toLowerCase(); diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index 8b2874b..c4fd5aa 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -87,15 +87,23 @@ class SortFilterMenu extends StatelessWidget { } } -const int _actionSortRecentMessages = 1; -const int _actionSortName = 2; -const int _actionSortLastSeen = 3; -const int _actionFilterAll = 4; -const int _actionFilterFavorites = 5; -const int _actionFilterUsers = 6; -const int _actionFilterRepeaters = 7; -const int _actionFilterRooms = 8; -const int _actionToggleUnreadOnly = 9; +sealed class _ContactsFilterAction { + const _ContactsFilterAction(); +} + +class _SortAction extends _ContactsFilterAction { + final ContactSortOption option; + const _SortAction(this.option); +} + +class _TypeFilterAction extends _ContactsFilterAction { + final ContactTypeFilter filter; + const _TypeFilterAction(this.filter); +} + +class _ToggleUnreadAction extends _ContactsFilterAction { + const _ToggleUnreadAction(); +} class ContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; @@ -118,24 +126,24 @@ class ContactsFilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return SortFilterMenu( + return SortFilterMenu<_ContactsFilterAction>( tooltip: l10n.listFilter_tooltip, sections: [ SortFilterMenuSection( title: l10n.listFilter_sortBy, options: [ SortFilterMenuOption( - value: _actionSortRecentMessages, + value: _SortAction(ContactSortOption.recentMessages), label: l10n.listFilter_latestMessages, checked: sortOption == ContactSortOption.recentMessages, ), SortFilterMenuOption( - value: _actionSortLastSeen, + value: _SortAction(ContactSortOption.lastSeen), label: l10n.listFilter_heardRecently, checked: sortOption == ContactSortOption.lastSeen, ), SortFilterMenuOption( - value: _actionSortName, + value: _SortAction(ContactSortOption.name), label: l10n.listFilter_az, checked: sortOption == ContactSortOption.name, ), @@ -145,32 +153,32 @@ class ContactsFilterMenu extends StatelessWidget { title: l10n.listFilter_filters, options: [ SortFilterMenuOption( - value: _actionFilterAll, + value: _TypeFilterAction(ContactTypeFilter.all), label: l10n.listFilter_all, checked: typeFilter == ContactTypeFilter.all, ), SortFilterMenuOption( - value: _actionFilterFavorites, + value: _TypeFilterAction(ContactTypeFilter.favorites), label: l10n.listFilter_favorites, checked: typeFilter == ContactTypeFilter.favorites, ), SortFilterMenuOption( - value: _actionFilterUsers, + value: _TypeFilterAction(ContactTypeFilter.users), label: l10n.listFilter_users, checked: typeFilter == ContactTypeFilter.users, ), SortFilterMenuOption( - value: _actionFilterRepeaters, + value: _TypeFilterAction(ContactTypeFilter.repeaters), label: l10n.listFilter_repeaters, checked: typeFilter == ContactTypeFilter.repeaters, ), SortFilterMenuOption( - value: _actionFilterRooms, + value: _TypeFilterAction(ContactTypeFilter.rooms), label: l10n.listFilter_roomServers, checked: typeFilter == ContactTypeFilter.rooms, ), SortFilterMenuOption( - value: _actionToggleUnreadOnly, + value: const _ToggleUnreadAction(), label: l10n.listFilter_unreadOnly, checked: showUnreadOnly, ), @@ -179,39 +187,32 @@ class ContactsFilterMenu extends StatelessWidget { ], onSelected: (action) { switch (action) { - case _actionSortRecentMessages: - onSortChanged(ContactSortOption.recentMessages); - break; - case _actionSortName: - onSortChanged(ContactSortOption.name); - break; - case _actionSortLastSeen: - onSortChanged(ContactSortOption.lastSeen); - break; - case _actionFilterAll: - onTypeFilterChanged(ContactTypeFilter.all); - break; - case _actionFilterUsers: - onTypeFilterChanged(ContactTypeFilter.users); - break; - case _actionFilterFavorites: - onTypeFilterChanged(ContactTypeFilter.favorites); - break; - case _actionFilterRepeaters: - onTypeFilterChanged(ContactTypeFilter.repeaters); - break; - case _actionFilterRooms: - onTypeFilterChanged(ContactTypeFilter.rooms); - break; - case _actionToggleUnreadOnly: + case _SortAction(:final option): + onSortChanged(option); + case _TypeFilterAction(:final filter): + onTypeFilterChanged(filter); + case _ToggleUnreadAction(): onUnreadOnlyChanged(!showUnreadOnly); - break; } }, ); } } +sealed class _DiscoveryFilterAction { + const _DiscoveryFilterAction(); +} + +class _DiscoverySortAction extends _DiscoveryFilterAction { + final ContactSortOption option; + const _DiscoverySortAction(this.option); +} + +class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction { + final ContactTypeFilter filter; + const _DiscoveryTypeFilterAction(this.filter); +} + class DiscoveryContactsFilterMenu extends StatelessWidget { final ContactSortOption sortOption; final ContactTypeFilter typeFilter; @@ -229,19 +230,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return SortFilterMenu( + return SortFilterMenu<_DiscoveryFilterAction>( tooltip: l10n.listFilter_tooltip, sections: [ SortFilterMenuSection( title: l10n.listFilter_sortBy, options: [ SortFilterMenuOption( - value: _actionSortLastSeen, + value: _DiscoverySortAction(ContactSortOption.lastSeen), label: l10n.listFilter_heardRecently, checked: sortOption == ContactSortOption.lastSeen, ), SortFilterMenuOption( - value: _actionSortName, + value: _DiscoverySortAction(ContactSortOption.name), label: l10n.listFilter_az, checked: sortOption == ContactSortOption.name, ), @@ -251,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget { title: l10n.listFilter_filters, options: [ SortFilterMenuOption( - value: _actionFilterAll, + value: _DiscoveryTypeFilterAction(ContactTypeFilter.all), label: l10n.listFilter_all, checked: typeFilter == ContactTypeFilter.all, ), SortFilterMenuOption( - value: _actionFilterUsers, + value: _DiscoveryTypeFilterAction(ContactTypeFilter.users), label: l10n.listFilter_users, checked: typeFilter == ContactTypeFilter.users, ), SortFilterMenuOption( - value: _actionFilterRepeaters, + value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters), label: l10n.listFilter_repeaters, checked: typeFilter == ContactTypeFilter.repeaters, ), SortFilterMenuOption( - value: _actionFilterRooms, + value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms), label: l10n.listFilter_roomServers, checked: typeFilter == ContactTypeFilter.rooms, ), @@ -275,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget { ], onSelected: (action) { switch (action) { - case _actionSortName: - onSortChanged(ContactSortOption.name); - break; - case _actionSortLastSeen: - onSortChanged(ContactSortOption.lastSeen); - break; - case _actionFilterAll: - onTypeFilterChanged(ContactTypeFilter.all); - break; - case _actionFilterUsers: - onTypeFilterChanged(ContactTypeFilter.users); - break; - case _actionFilterFavorites: - onTypeFilterChanged(ContactTypeFilter.favorites); - break; - case _actionFilterRepeaters: - onTypeFilterChanged(ContactTypeFilter.repeaters); - break; - case _actionFilterRooms: - onTypeFilterChanged(ContactTypeFilter.rooms); - break; + case _DiscoverySortAction(:final option): + onSortChanged(option); + case _DiscoveryTypeFilterAction(:final filter): + onTypeFilterChanged(filter); } }, ); From 4b744184c2b27072e6a07dbb31332d600390211f Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:09:54 -0700 Subject: [PATCH 21/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/models/contact.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 858d712..c047622 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,5 +1,4 @@ import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; import 'package:meshcore_open/utils/app_logger.dart'; import '../connector/meshcore_protocol.dart'; From 9265daaf16aeaba5f6e4c5e120bdfd7d413e3bc3 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:09 -0700 Subject: [PATCH 22/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/tcp_screen.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 02b9b5a..11ab80a 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:meshcore_open/models/app_settings.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; From dc85e7a41c41aabcb684458116292dc9ace0e542 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:17 -0700 Subject: [PATCH 23/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/path_trace_map.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index d50a185..86bba2a 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -361,7 +361,6 @@ class _PathTraceMapScreenState extends State { contact.longitude! + offsetDeg * sin(angle), ); targetGuessed = true; - targetGuessed = true; } } } From 3593cfa84397fb019c4d90f2a16cd9e8bac667d5 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Sat, 14 Mar 2026 18:10:44 -0700 Subject: [PATCH 24/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/path_trace_map.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 86bba2a..e3c877b 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -312,8 +312,8 @@ class _PathTraceMapScreenState extends State { targetPos = LatLng(target!.latitude!, target!.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. + // 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; From 28a423e0a8df22bbaa3ad5e00cce7b271288fe8e Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:14:39 -0700 Subject: [PATCH 25/75] 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) --- lib/connector/meshcore_connector.dart | 4 ++-- lib/screens/path_trace_map.dart | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 86484d8..8f93f33 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4757,14 +4757,14 @@ class MeshCoreConnector extends ChangeNotifier { hasLocation && latitude != null && latitude.abs() <= 90 && - longitude != 0 + (latitude != 0 || longitude != 0) ? latitude : existing.latitude, longitude: hasLocation && longitude != null && longitude.abs() <= 180 && - longitude != 0 + (latitude != 0 || longitude != 0) ? longitude : existing.longitude, name: hasName ? name : existing.name, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index e3c877b..e64a906 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -93,7 +93,7 @@ class _PathTraceMapScreenState extends State { ValueKey _mapKey = const ValueKey('initial'); double _pathDistanceMeters = 0.0; bool _showNodeLabels = true; - Contact? target; + Contact? _targetContact; String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes @@ -305,11 +305,12 @@ class _PathTraceMapScreenState extends State { // Compute endpoint position for the target contact. LatLng? targetPos; bool targetGuessed = false; - target = widget.targetContact; + _targetContact = widget.targetContact; - if (target != null) { - if (target?.hasLocation ?? false) { - targetPos = LatLng(target!.latitude!, target!.longitude!); + 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 (flipPathAround/reversePathAround), the target-side hop @@ -334,7 +335,7 @@ class _PathTraceMapScreenState extends State { 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), @@ -344,7 +345,7 @@ class _PathTraceMapScreenState extends State { final lat = inferredPositions[lastHop]!.latitude; final lon = inferredPositions[lastHop]!.longitude; 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), @@ -355,7 +356,7 @@ class _PathTraceMapScreenState extends State { final contact = pathContacts[lastHop]; if (contact != null && contact.hasLocation) { const offsetDeg = 0.003; - final angle = (target!.publicKey[1] / 255.0) * 2 * pi; + final angle = (tc.publicKey[1] / 255.0) * 2 * pi; targetPos = LatLng( contact.latitude! + offsetDeg * cos(angle), contact.longitude! + offsetDeg * sin(angle), @@ -387,7 +388,7 @@ class _PathTraceMapScreenState extends State { hopLast = hop; } if (targetPos != null) { - if (target != null && target!.type == advTypeChat) { + if (_targetContact != null && _targetContact!.type == advTypeChat) { _points.add(targetPos); } } @@ -479,7 +480,8 @@ class _PathTraceMapScreenState extends State { ], ), ), - if (_hasData) _buildMapPathTrace(context, tileCache, target), + if (_hasData) + _buildMapPathTrace(context, tileCache, _targetContact), if (_points.isEmpty && !_hasData && !_isLoading && From 6dfb7a4b6941bc828069258724dc24988e5f7266 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:41:21 -0700 Subject: [PATCH 26/75] fix: auto-add flag parsing, contact cache restore, and USB reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- lib/connector/meshcore_connector.dart | 17 ++++++++++----- lib/connector/meshcore_connector_usb.dart | 2 ++ lib/services/usb_serial_service_native.dart | 23 +++++++++++++++++++++ lib/services/usb_serial_service_web.dart | 11 ++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 8f93f33..1af3c0b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -708,6 +708,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); } @@ -1540,6 +1543,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.delayed(const Duration(milliseconds: 10)); } else if (_activeTransport == MeshCoreTransportType.tcp) { await _tcpConnector.write(data); } else { @@ -4837,11 +4844,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'); } diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart index 74e7355..56718bc 100644 --- a/lib/connector/meshcore_connector_usb.dart +++ b/lib/connector/meshcore_connector_usb.dart @@ -64,6 +64,8 @@ class MeshCoreUsbManager { Future write(Uint8List data) => _service.write(data); + Future writeRaw(Uint8List data) => _service.writeRaw(data); + // --- Label management --- void updateConnectedLabel(String selfName) { _service.updateConnectedLabel(selfName); diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index fca3d19..c1d3946 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -189,6 +189,10 @@ class UsbSerialService { serial.setStopBits1(); serial.setFlowControlNone(); serial.setRTS(false); + // Toggle DTR low→high so the device sees a fresh connection even + // if the previous disconnect didn't cleanly signal DTR drop. + serial.setDTR(false); + await Future.delayed(const Duration(milliseconds: 50)); serial.setDTR(true); _serial = serial; // Update the normalized port name to whichever candidate succeeded. @@ -249,6 +253,23 @@ class UsbSerialService { _status = UsbSerialStatus.connected; } + Future writeRaw(Uint8List data) async { + if (!isConnected) { + throw StateError('USB serial port is not open'); + } + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('write', { + 'data': data, + }); + } on PlatformException catch (error) { + throw StateError(error.message ?? error.code); + } + } else { + _serial!.write(data); + } + } + Future write(Uint8List data) async { if (!isConnected) { throw StateError('USB serial port is not open'); @@ -300,6 +321,7 @@ class UsbSerialService { _serial = null; try { if (serial?.isOpen() == FlOpenStatus.open) { + serial?.setDTR(false); serial?.closePort(); } } catch (_) { @@ -350,6 +372,7 @@ class UsbSerialService { final serial = _serial; try { if (serial?.isOpen() == FlOpenStatus.open) { + serial?.setDTR(false); serial?.closePort(); // synchronous C call — kills the SerialThread } } catch (_) {} diff --git a/lib/services/usb_serial_service_web.dart b/lib/services/usb_serial_service_web.dart index 4c83d7d..5261308 100644 --- a/lib/services/usb_serial_service_web.dart +++ b/lib/services/usb_serial_service_web.dart @@ -127,6 +127,17 @@ class UsbSerialService { } } + Future writeRaw(Uint8List data) async { + if (!isConnected || _writer == null) { + throw StateError('USB serial port is not open'); + } + final promise = _writer!.callMethod>( + 'write'.toJS, + data.toJS, + ); + await promise.toDart; + } + Future write(Uint8List data) async { if (!isConnected || _writer == null) { throw StateError('USB serial port is not open'); From 60e8ee013053a06d1f8c74d8f654d3ecf97f0288 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:41:57 -0700 Subject: [PATCH 27/75] fix: simplify method call for writing data in UsbSerialService --- lib/services/usb_serial_service_native.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/services/usb_serial_service_native.dart b/lib/services/usb_serial_service_native.dart index c1d3946..40861db 100644 --- a/lib/services/usb_serial_service_native.dart +++ b/lib/services/usb_serial_service_native.dart @@ -259,9 +259,7 @@ class UsbSerialService { } if (_useAndroidUsbHost) { try { - await _androidMethodChannel.invokeMethod('write', { - 'data': data, - }); + await _androidMethodChannel.invokeMethod('write', {'data': data}); } on PlatformException catch (error) { throw StateError(error.message ?? error.code); } From 64d75dde45e6e842012956b790ac1e8c5bf0763a Mon Sep 17 00:00:00 2001 From: zjs81 Date: Sat, 14 Mar 2026 18:46:29 -0700 Subject: [PATCH 28/75] chore: update version to 7.0.0+8 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 4831e67..663622b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 From be690c81943073b5ce8e172c4e49d266d528036a Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Sun, 15 Mar 2026 16:48:40 -0400 Subject: [PATCH 29/75] fix: provide AppSettingsService in tcp_flow_test TcpScreen.initState reads AppSettingsService from context to pre-fill host/port fields, but the test helper only provided MeshCoreConnector. Switch to MultiProvider so AppSettingsService is also in the widget tree. --- test/screens/tcp_flow_test.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 725388a..1d8174c 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -6,6 +6,7 @@ import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/l10n/app_localizations.dart'; import 'package:meshcore_open/screens/scanner_screen.dart'; import 'package:meshcore_open/screens/tcp_screen.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; class _FakeMeshCoreConnector extends MeshCoreConnector { _FakeMeshCoreConnector(); @@ -44,8 +45,13 @@ Widget _buildTestApp({ required Widget child, Locale? locale, }) { - return ChangeNotifierProvider.value( - value: connector, + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: connector), + ChangeNotifierProvider( + create: (_) => AppSettingsService(), + ), + ], child: MaterialApp( locale: locale, localizationsDelegates: AppLocalizations.localizationsDelegates, From faba1208234e74f593d4ddf8badb8430ea9c9a7c Mon Sep 17 00:00:00 2001 From: Stephan Rodemeier Date: Sun, 15 Mar 2026 23:01:38 +0100 Subject: [PATCH 30/75] Add more explicit platform support table The platform support was a bit vague, this adds a table to better convey the differences. --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3b230dd..2f87e91 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ### Device Management -- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth +- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP - **Device Settings**: Configure radio parameters, power settings, and network options - **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves - **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon) @@ -75,10 +75,16 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh ### Platform Support -- ✅ **Android**: Full support (API 21+) -- ✅ **iOS**: Full support (iOS 12+) -- 🚧 **Desktop**: Limited support (macOS/Linux/Windows) -- 🚧 **Web**: Under construction (Chrome) +| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web | +|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:| +| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ | +| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌
(requires websocket bridge) | +| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ### Dependencies @@ -189,6 +195,7 @@ Messages are transmitted as binary frames using a custom protocol optimized for ### App Settings - **Theme**: System default, light, or dark mode +- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian) - **Notifications**: Configurable for messages, channels, and node advertisements - **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types - **Message Retry**: Automatic retry with configurable path clearing From 723bf7293c0765510096dbf0a29a324493d62f0d Mon Sep 17 00:00:00 2001 From: ericz Date: Tue, 17 Mar 2026 21:56:42 +0100 Subject: [PATCH 31/75] location aware channel_message_path --- .gitignore | 3 +- lib/screens/channel_message_path_screen.dart | 93 +++++++++++--------- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index a312737..779856c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ secrets.dart .DS_Store .AppleDouble .LSOverride +macos/Flutter/GeneratedPluginRegistrant.swift # iOS **/ios/Pods/ @@ -85,4 +86,4 @@ keystore.properties .vscode/settings.json # Cloudflare Wrangler -.wrangler \ No newline at end of file +.wrangler diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 747c2bf..e2c5b49 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,8 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final contacts = connector.allContacts; - final hops = _buildPathHops(primaryPath, contacts, l10n); + final hops = _buildPathHops(primaryPath, connector, l10n); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( primaryPath.length, @@ -365,8 +364,7 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final contacts = connector.allContacts; - final hops = _buildPathHops(selectedPath, contacts, context.l10n); + final hops = _buildPathHops(selectedPath, connector, context.l10n); final points = []; @@ -787,17 +785,62 @@ class _ObservedPath { List<_PathHop> _buildPathHops( Uint8List pathBytes, - List contacts, + MeshCoreConnector connector, AppLocalizations l10n, ) { + if (pathBytes.isEmpty) return const []; + final candidatesByPrefix = >{}; + for (final contact in connector.allContacts) { + if (contact.publicKey.isEmpty) continue; + if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { + continue; + } + final prefix = contact.publicKey.first; + candidatesByPrefix.putIfAbsent(prefix, () => []).add(contact); + } + for (final candidates in candidatesByPrefix.values) { + candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); + } + final startPoint = + (connector.selfLatitude != null && connector.selfLongitude != null) + ? LatLng(connector.selfLatitude!, connector.selfLongitude!) + : null; + var previousPosition = startPoint; + final distance = Distance(); + final hops = <_PathHop>[]; for (var i = 0; i < pathBytes.length; i++) { - final prefix = pathBytes[i]; - final contact = _matchContactForPrefix(contacts, prefix); + final searchPoint = i == 0 ? startPoint : previousPosition; + final candidates = candidatesByPrefix[pathBytes[i]]; + Contact? contact; + if (candidates != null && candidates.isNotEmpty) { + var bestIndex = 0; + if (searchPoint != null) { + var bestDistance = double.infinity; + for (var j = 0; j < candidates.length; j++) { + final candidate = candidates[j]; + if (!candidate.hasLocation) continue; + final currentDistance = distance( + searchPoint, + LatLng(candidate.latitude!, candidate.longitude!), + ); + if (currentDistance < bestDistance) { + bestDistance = currentDistance; + bestIndex = j; + } + } + } + contact = candidates.removeAt(bestIndex); + if (candidates.isEmpty) { + candidatesByPrefix.remove(pathBytes[i]); + } + } + + previousPosition = _resolvePosition(contact); hops.add( _PathHop( index: i + 1, - prefix: prefix, + prefix: pathBytes[i], contact: contact, position: _resolvePosition(contact), l10n: l10n, @@ -807,44 +850,12 @@ List<_PathHop> _buildPathHops( return hops; } -Contact? _matchContactForPrefix(List contacts, int prefix) { - final matches = contacts - .where( - (contact) => - (contact.type == advTypeRepeater || contact.type == advTypeRoom) && - contact.publicKey.isNotEmpty && - contact.publicKey[0] == prefix, - ) - .toList(); - if (matches.isEmpty) return null; - - Contact? pickWhere(bool Function(Contact) predicate) { - for (final contact in matches) { - if (predicate(contact)) return contact; - } - return null; - } - - return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ?? - pickWhere((c) => c.type == advTypeRepeater) ?? - pickWhere(_hasValidLocation) ?? - matches.first; -} - LatLng? _resolvePosition(Contact? contact) { if (contact == null) return null; - if (!_hasValidLocation(contact)) return null; + if (!contact.hasLocation) return null; return LatLng(contact.latitude!, contact.longitude!); } -bool _hasValidLocation(Contact contact) { - final lat = contact.latitude; - final lon = contact.longitude; - if (lat == null || lon == null) return false; - if (lat == 0 && lon == 0) return false; - return true; -} - String _formatPrefix(int prefix) { return prefix.toRadixString(16).padLeft(2, '0').toUpperCase(); } From d2df2b0beda0f3826bd6d712be77e474e87b8066 Mon Sep 17 00:00:00 2001 From: ericszimmermann Date: Tue, 17 Mar 2026 22:23:23 +0100 Subject: [PATCH 32/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/channel_message_path_screen.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index e2c5b49..d2f8d81 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -836,13 +836,16 @@ List<_PathHop> _buildPathHops( } } - previousPosition = _resolvePosition(contact); + final resolvedPosition = _resolvePosition(contact); + if (resolvedPosition != null) { + previousPosition = resolvedPosition; + } hops.add( _PathHop( index: i + 1, prefix: pathBytes[i], contact: contact, - position: _resolvePosition(contact), + position: resolvedPosition, l10n: l10n, ), ); From 11cb14a9256b53b43a937b55fe5810dbc2d10163 Mon Sep 17 00:00:00 2001 From: ericz Date: Tue, 17 Mar 2026 23:22:23 +0100 Subject: [PATCH 33/75] focus on hop if you click on one in the legend. --- lib/screens/channel_message_path_screen.dart | 29 +++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ 2 files changed, 31 insertions(+) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index d2f8d81..0092f22 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -302,10 +302,12 @@ class _ChannelMessagePathMapScreenState extends State { static const double _labelZoomThreshold = 8.5; + final MapController _mapController = MapController(); Uint8List? _selectedPath; double _pathDistance = 0.0; bool _showNodeLabels = true; bool _didReceivePositionUpdate = false; + int? _focusedHopIndex; @override void initState() { @@ -336,6 +338,22 @@ class _ChannelMessagePathMapScreenState return totalDistance; } + void _focusHop(_PathHop hop) { + if (!hop.hasLocation) return; + final targetZoom = _didReceivePositionUpdate + ? max(_mapController.camera.zoom, 14.0) + : 14.0; + _mapController.move(hop.position!, targetZoom); + } + + void _onHopTapped(_PathHop hop) { + _focusHop(hop); + if (!mounted) return; + setState(() { + _focusedHopIndex = hop.index; + }); + } + @override Widget build(BuildContext context) { return Consumer( @@ -419,6 +437,7 @@ class _ChannelMessagePathMapScreenState children: [ FlutterMap( key: mapKey, + mapController: _mapController, options: MapOptions( initialCenter: initialCenter, initialZoom: initialZoom, @@ -470,6 +489,7 @@ class _ChannelMessagePathMapScreenState ) { setState(() { _selectedPath = observedPaths[index].pathBytes; + _focusedHopIndex = null; }); }), if (points.isEmpty) @@ -725,8 +745,17 @@ class _ChannelMessagePathMapScreenState separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final hop = hops[index]; + final isFocused = _focusedHopIndex == hop.index; return ListTile( dense: true, + enabled: hop.hasLocation, + selected: isFocused, + selectedTileColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + onTap: hop.hasLocation + ? () => _onHopTapped(hop) + : null, leading: CircleAvatar( radius: 14, child: Text( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d2ea57e..4084d9b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ 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 @@ -20,6 +21,7 @@ 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")) From 7b3c09973604c592bca8d3002edd537788333d2a Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 18 Mar 2026 06:52:08 +0100 Subject: [PATCH 34/75] reduce zoomlevel --- lib/screens/channel_message_path_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 0092f22..76273c4 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -341,8 +341,8 @@ class _ChannelMessagePathMapScreenState void _focusHop(_PathHop hop) { if (!hop.hasLocation) return; final targetZoom = _didReceivePositionUpdate - ? max(_mapController.camera.zoom, 14.0) - : 14.0; + ? max(_mapController.camera.zoom, 10.0) + : 12.0; _mapController.move(hop.position!, targetZoom); } From 87d11c2e6b3d0e1e21099ee9c39ba852c863e92d Mon Sep 17 00:00:00 2001 From: ericszimmermann Date: Wed, 18 Mar 2026 07:00:16 +0100 Subject: [PATCH 35/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/channel_message_path_screen.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 76273c4..f64cb53 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -848,7 +848,11 @@ List<_PathHop> _buildPathHops( var bestDistance = double.infinity; for (var j = 0; j < candidates.length; j++) { final candidate = candidates[j]; - if (!candidate.hasLocation) continue; + if (!candidate.hasLocation || + candidate.latitude == null || + candidate.longitude == null) { + continue; + } final currentDistance = distance( searchPoint, LatLng(candidate.latitude!, candidate.longitude!), From b88e5e647ad9a0cda4a994210aa84ae6717148b0 Mon Sep 17 00:00:00 2001 From: ericszimmermann Date: Wed, 18 Mar 2026 08:06:22 +0100 Subject: [PATCH 36/75] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/screens/channel_message_path_screen.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index f64cb53..ea07eae 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -889,7 +889,10 @@ List<_PathHop> _buildPathHops( LatLng? _resolvePosition(Contact? contact) { if (contact == null) return null; if (!contact.hasLocation) return null; - return LatLng(contact.latitude!, contact.longitude!); + final latitude = contact.latitude; + final longitude = contact.longitude; + if (latitude == null || longitude == null) return null; + return LatLng(latitude, longitude); } String _formatPrefix(int prefix) { From 4962a48e64222217c3678fa5294b822ca287dca8 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Fri, 20 Mar 2026 01:54:31 -0700 Subject: [PATCH 37/75] Msg Retry fixes, channel message fixes. Notification fixes. Make more desktop friendly. Enhance retry algo. Fix predicted location clustering add retries to reactions and fix the reactions in private DMS centralize and cleanup code in var areas --- android/app/build.gradle.kts | 2 +- lib/connector/meshcore_connector.dart | 591 +++++++++----- lib/connector/meshcore_protocol.dart | 4 +- lib/helpers/link_handler.dart | 39 + lib/helpers/path_helper.dart | 31 + lib/helpers/reaction_helper.dart | 44 + lib/l10n/app_bg.arb | 23 +- lib/l10n/app_de.arb | 23 +- lib/l10n/app_en.arb | 17 + lib/l10n/app_es.arb | 23 +- lib/l10n/app_fr.arb | 23 +- lib/l10n/app_it.arb | 23 +- lib/l10n/app_localizations.dart | 66 ++ lib/l10n/app_localizations_bg.dart | 45 + lib/l10n/app_localizations_de.dart | 43 + lib/l10n/app_localizations_en.dart | 42 + lib/l10n/app_localizations_es.dart | 43 + lib/l10n/app_localizations_fr.dart | 44 + lib/l10n/app_localizations_it.dart | 44 + lib/l10n/app_localizations_nl.dart | 43 + lib/l10n/app_localizations_pl.dart | 43 + lib/l10n/app_localizations_pt.dart | 43 + lib/l10n/app_localizations_ru.dart | 44 + lib/l10n/app_localizations_sk.dart | 42 + lib/l10n/app_localizations_sl.dart | 43 + lib/l10n/app_localizations_sv.dart | 42 + lib/l10n/app_localizations_uk.dart | 43 + lib/l10n/app_localizations_zh.dart | 37 + lib/l10n/app_nl.arb | 23 +- lib/l10n/app_pl.arb | 23 +- lib/l10n/app_pt.arb | 23 +- lib/l10n/app_ru.arb | 23 +- lib/l10n/app_sk.arb | 23 +- lib/l10n/app_sl.arb | 23 +- lib/l10n/app_sv.arb | 23 +- lib/l10n/app_uk.arb | 23 +- lib/l10n/app_zh.arb | 23 +- lib/models/app_settings.dart | 35 + lib/models/channel_message.dart | 4 + lib/models/contact.dart | 14 +- lib/models/message.dart | 7 +- lib/models/path_history.dart | 12 +- lib/models/path_selection.dart | 41 + lib/screens/app_settings_screen.dart | 112 +++ lib/screens/channel_chat_screen.dart | 32 +- lib/screens/channels_screen.dart | 153 ++-- lib/screens/chat_screen.dart | 142 +++- lib/screens/contacts_screen.dart | 122 +-- lib/screens/discovery_screen.dart | 11 +- lib/screens/map_screen.dart | 57 +- lib/services/app_settings_service.dart | 24 + lib/services/message_retry_service.dart | 610 +++++--------- lib/services/notification_service.dart | 21 +- lib/services/path_history_service.dart | 340 ++++++-- lib/storage/channel_message_store.dart | 2 + lib/storage/message_store.dart | 8 + lib/widgets/path_management_dialog.dart | 69 +- test/helpers/path_helper_test.dart | 36 + test/models/model_changes_test.dart | 357 ++++++++ test/services/path_history_service_test.dart | 815 +++++++++++++++++++ test/services/retry_and_protocol_test.dart | 628 ++++++++++++++ 61 files changed, 4509 insertions(+), 900 deletions(-) create mode 100644 lib/helpers/path_helper.dart create mode 100644 test/helpers/path_helper_test.dart create mode 100644 test/models/model_changes_test.dart create mode 100644 test/services/path_history_service_test.dart create mode 100644 test/services/retry_and_protocol_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e0a8029..c8028e0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.meshcore.meshcore_open" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "29.0.14206865" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7211992..d00d49a 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -171,6 +171,11 @@ class MeshCoreConnector extends ChangeNotifier { // 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(); + DateTime _lastRadioRxTime = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _lastContactMsgRxTime = DateTime.fromMillisecondsSinceEpoch(0); + static const int _radioQuietMs = 3000; + static const int _radioQuietMaxWaitMs = 3000; + static const int _contactMsgBackoffMs = 5000; bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _hasReceivedDeviceInfo = false; @@ -694,24 +699,32 @@ class MeshCoreConnector extends ChangeNotifier { _loadChannelOrder(); // Initialize retry service callbacks - _retryService?.initialize( - sendMessageCallback: _sendMessageDirect, - addMessageCallback: _addMessage, - updateMessageCallback: _updateMessage, - clearContactPathCallback: clearContactPath, - setContactPathCallback: setContactPath, - calculateTimeoutCallback: + _retryService?.initialize(RetryServiceConfig( + sendMessage: _sendMessageDirect, + addMessage: _addMessage, + updateMessage: _updateMessage, + clearContactPath: clearContactPath, + setContactPath: setContactPath, + calculateTimeout: (pathLength, messageBytes, {String? contactKey}) => calculateTimeout( pathLength: pathLength, messageBytes: messageBytes, contactKey: contactKey, ), - getSelfPublicKeyCallback: () => _selfPublicKey, - prepareContactOutboundTextCallback: prepareContactOutboundText, + getSelfPublicKey: () => _selfPublicKey, + prepareContactOutboundText: prepareContactOutboundText, appSettingsService: appSettingsService, debugLogService: _appDebugLogService, - recordPathResultCallback: _recordPathResult, - onDeliveryObservedCallback: + recordPathResult: _recordPathResult, + selectRetryPath: + (contactKey, attemptIndex, maxRetries, recentSelections) => + _selectAutoPathForAttempt( + contactKey, + attemptIndex: attemptIndex, + maxRetries: maxRetries, + recentSelections: recentSelections, + ), + onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) { final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; _timeoutPredictionService?.recordObservation( @@ -722,7 +735,9 @@ class MeshCoreConnector extends ChangeNotifier { secondsSinceLastRx: secSinceRx, ); }, - ); + )); + final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5; + _retryService?.setMaxRetries(maxRetries); } Future loadContactCache() async { @@ -753,22 +768,61 @@ class MeshCoreConnector extends ChangeNotifier { } } - void _sendMessageDirect( + Future _waitForRadioQuiet() async { + // Wait for backoff after receiving a contact message (avoid collision + // with their transmission still propagating through repeaters) + final msSinceContactMsg = DateTime.now() + .difference(_lastContactMsgRxTime) + .inMilliseconds; + if (msSinceContactMsg < _contactMsgBackoffMs) { + final waitMs = _contactMsgBackoffMs - msSinceContactMsg; + debugPrint('Contact message backoff: waiting ${waitMs}ms'); + await Future.delayed(Duration(milliseconds: waitMs)); + } + + // Then wait for radio silence (no RF activity for 3s) + final msSinceRx = DateTime.now() + .difference(_lastRadioRxTime) + .inMilliseconds; + if (msSinceRx >= _radioQuietMs) return; + + final deadline = DateTime.now().add( + const Duration(milliseconds: _radioQuietMaxWaitMs), + ); + while (DateTime.now().isBefore(deadline)) { + final quiet = DateTime.now().difference(_lastRadioRxTime).inMilliseconds; + if (quiet >= _radioQuietMs) { + debugPrint('Radio quiet for ${quiet}ms, proceeding with send'); + return; + } + await Future.delayed(const Duration(milliseconds: 200)); + } + debugPrint( + 'Radio quiet wait exceeded ${_radioQuietMaxWaitMs}ms, sending anyway', + ); + } + + Future _sendMessageDirect( Contact contact, String text, int attempt, int timestampSeconds, ) async { if (!isConnected || text.isEmpty) return; - final outboundText = prepareContactOutboundText(contact, text); - await sendFrame( - buildSendTextMsgFrame( - contact.publicKey, - outboundText, - attempt: attempt, - timestampSeconds: timestampSeconds, - ), - ); + try { + await _waitForRadioQuiet(); + final outboundText = prepareContactOutboundText(contact, text); + await sendFrame( + buildSendTextMsgFrame( + contact.publicKey, + outboundText, + attempt: attempt, + timestampSeconds: timestampSeconds, + ), + ); + } catch (e) { + appLogger.error('Failed to send message: $e', tag: 'Connector'); + } } void _updateMessage(Message message) { @@ -784,6 +838,20 @@ class MeshCoreConnector extends ChangeNotifier { notifyListeners(); } } + + // If this is a reaction message, update the target message's reaction status + final reactionInfo = ReactionHelper.parseReaction(message.text); + if (reactionInfo != null && + (message.status == MessageStatus.delivered || + message.status == MessageStatus.failed)) { + final contactKey2 = pubKeyToHex(message.senderKey); + _setReactionStatus(contactKey2, reactionInfo, message.status); + _messageStore.saveMessages( + contactKey2, + _conversations[contactKey2] ?? [], + ); + notifyListeners(); + } } void _recordPathResult( @@ -793,35 +861,68 @@ class MeshCoreConnector extends ChangeNotifier { int? tripTimeMs, ) { if (_pathHistoryService == null) return; + final settings = _appSettingsService?.settings; _pathHistoryService!.recordPathResult( contactPubKeyHex, selection, success: success, tripTimeMs: tripTimeMs, + successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2, + failureDecrement: settings?.routeWeightFailureDecrement ?? 0.2, + maxWeight: settings?.maxRouteWeight ?? 5.0, ); + + // Flood path attribution: when a flood delivery succeeds, credit the + // contact's current device path so the route the ACK traveled back + // through gets a weight boost in the path history. + if (selection.useFlood && success) { + final contact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == contactPubKeyHex, + orElse: () => null, + ); + if (contact != null && + contact.pathLength >= 0 && + contact.path.isNotEmpty) { + _pathHistoryService!.recordFloodPathAttribution( + contactPubKeyHex: contactPubKeyHex, + pathBytes: contact.path, + hopCount: contact.pathLength, + tripTimeMs: tripTimeMs, + successIncrement: settings?.routeWeightSuccessIncrement ?? 0.2, + maxWeight: settings?.maxRouteWeight ?? 5.0, + ); + } + + // Request a fresh contact from the device so the next flood + // attribution uses the most up-to-date path. + if (contact != null) { + unawaited(getContactByKey(contact.publicKey)); + } + } } - Contact _applyAutoSelection(Contact contact, PathSelection? selection) { - if (selection == null || - selection.useFlood || - selection.pathBytes.isEmpty) { - return contact; + PathSelection? _selectAutoPathForAttempt( + String contactPubKeyHex, { + required int attemptIndex, + required int maxRetries, + List recentSelections = const [], + }) { + final hasKnownPaths = + _pathHistoryService?.getRecentPaths(contactPubKeyHex).isNotEmpty ?? false; + if (!hasKnownPaths) { + return null; } - return Contact( - publicKey: contact.publicKey, - name: contact.name, - type: contact.type, - flags: contact.flags, - pathLength: selection.hopCount >= 0 - ? selection.hopCount - : contact.pathLength, - path: Uint8List.fromList(selection.pathBytes), - latitude: contact.latitude, - longitude: contact.longitude, - lastSeen: contact.lastSeen, - lastMessageAt: contact.lastMessageAt, + final selection = _pathHistoryService?.selectPathForAttempt( + contactPubKeyHex, + attemptIndex: attemptIndex, + maxRetries: maxRetries, + recentSelections: recentSelections, ); + if (selection != null) { + _pathHistoryService?.recordPathAttempt(contactPubKeyHex, selection); + } + return selection; } Future startScan({ @@ -1730,47 +1831,43 @@ class MeshCoreConnector extends ChangeNotifier { Future sendMessage(Contact contact, String text) async { if (!isConnected || text.isEmpty) return; - // Handle auto-rotation if enabled - PathSelection? autoSelection; - if (_appSettingsService?.settings.autoRouteRotationEnabled == true) { - autoSelection = _pathHistoryService?.getNextAutoPathSelection( + // Check if this is a reaction - apply locally with pending status and route through retry service + final reactionInfo = ReactionHelper.parseReaction(text); + if (reactionInfo != null) { + _conversations.putIfAbsent(contact.publicKeyHex, () => []); + final messages = _conversations[contact.publicKeyHex]!; + + // Apply reaction locally with pending status + _processOutgoingContactReaction(messages, reactionInfo, contact); + _setReactionStatus( contact.publicKeyHex, + reactionInfo, + MessageStatus.pending, ); - if (autoSelection != null) { - _pathHistoryService?.recordPathAttempt( - contact.publicKeyHex, - autoSelection, - ); - if (!autoSelection.useFlood && autoSelection.pathBytes.isNotEmpty) { - await setContactPath( - contact, - Uint8List.fromList(autoSelection.pathBytes), - autoSelection.pathBytes.length, - ); - } + _messageStore.saveMessages(contact.publicKeyHex, messages); + notifyListeners(); + + // Route through retry service (same as normal messages) + // Don't use auto-rotation for reactions — just send directly + if (_retryService != null) { + _retryService!.sendMessageWithRetry(contact: contact, text: text); + } else { + final outboundText = prepareContactOutboundText(contact, text); + await sendFrame(buildSendTextMsgFrame(contact.publicKey, outboundText)); } + return; } if (_retryService != null) { - final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, autoSelection); - final selectedContact = _applyAutoSelection(contact, autoSelection); - await _retryService!.sendMessageWithRetry( - contact: selectedContact, - text: text, - pathSelection: autoSelection, - pathBytes: pathBytes, - pathLength: pathLength, - ); + await _retryService!.sendMessageWithRetry(contact: contact, text: text); } else { // Fallback to old behavior if retry service not initialized - final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, autoSelection); + final resolved = resolvePathSelection(contact); final message = Message.outgoing( contact.publicKey, text, - pathLength: pathLength, - pathBytes: pathBytes, + pathLength: resolved.useFlood ? -1 : resolved.hopCount, + pathBytes: Uint8List.fromList(resolved.pathBytes), ); _addMessage(contact.publicKeyHex, message); notifyListeners(); @@ -1808,6 +1905,16 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await Future.delayed(const Duration(milliseconds: 100)); } + final idx = _contacts.indexWhere( + (c) => c.publicKeyHex == contact.publicKeyHex, + ); + if (idx != -1) { + _contacts[idx] = _contacts[idx].copyWith( + pathLength: customPath.length, + path: customPath, + ); + notifyListeners(); + } } finally { completer.complete(); } @@ -1924,6 +2031,9 @@ class MeshCoreConnector extends ChangeNotifier { await _contactStore.saveContacts(_contacts); appLogger.info('Saved contacts to storage', tag: 'Connector'); + // Update any in-flight retries so they use the new path override + _retryService?.updatePendingContact(_contacts[index]); + // If setting a specific path (not flood, not auto), also sync with device if (pathLen != null && pathLen >= 0 && pathBytes != null) { appLogger.info('Sending path to device...', tag: 'Connector'); @@ -1942,27 +2052,27 @@ class MeshCoreConnector extends ChangeNotifier { final autoRotationEnabled = _appSettingsService?.settings.autoRouteRotationEnabled == true; if (autoRotationEnabled && contact.pathOverride == null) { - autoSelection = _pathHistoryService?.getNextAutoPathSelection( + final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5; + autoSelection = _selectAutoPathForAttempt( contact.publicKeyHex, + attemptIndex: 0, + maxRetries: maxRetries, ); - if (autoSelection != null) { - _pathHistoryService?.recordPathAttempt( - contact.publicKeyHex, - autoSelection, - ); - } } - final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, autoSelection) ?? -1; + final resolved = resolvePathSelection(contact, selection: autoSelection); - if (pathLength < 0) { + if (resolved.useFlood) { await clearContactPath(contact); } else { - await setContactPath(contact, pathBytes, pathLength); + await setContactPath( + contact, + Uint8List.fromList(resolved.pathBytes), + resolved.hopCount, + ); } - return _selectionFromPath(pathLength, pathBytes); + return resolved; } void trackRepeaterAck({ @@ -2626,6 +2736,7 @@ class MeshCoreConnector extends ChangeNotifier { case pushCodeStatusResponse: break; case pushCodeLogRxData: + _lastRadioRxTime = DateTime.now(); _handleRxData(frame); _handleLogRxData(frame); break; @@ -2929,16 +3040,17 @@ class MeshCoreConnector extends ChangeNotifier { /// Physics-based worst-case timeout (ceiling). int _physicsMaxTimeout(int pathLength, int airtime) { if (pathLength < 0) { + // Match firmware: SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * airtime) return 500 + (16 * airtime); } else { 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; + // Same as max for flood — firmware uses a single formula + return 500 + (16 * airtime); } else { return airtime * (pathLength + 1); } @@ -2955,7 +3067,7 @@ class MeshCoreConnector extends ChangeNotifier { final physicsMin = _physicsMinTimeout(pathLength, airtime); final physicsMax = _physicsMaxTimeout(pathLength, airtime); - // Try ML-based prediction, clamped between physics bounds + // Try ML-based prediction final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; final mlTimeout = _timeoutPredictionService?.predictTimeout( contactKey: contactKey, @@ -2964,9 +3076,14 @@ class MeshCoreConnector extends ChangeNotifier { secondsSinceLastRx: secSinceRx, ); if (mlTimeout != null) { + if (pathLength < 0) { + // Flood: trust ML, only enforce firmware formula as floor + return mlTimeout.clamp(physicsMin, mlTimeout); + } return mlTimeout.clamp(physicsMin, physicsMax); } + // No ML data — use firmware formula return physicsMax; } @@ -3255,6 +3372,9 @@ class MeshCoreConnector extends ChangeNotifier { } if (message != null) { + if (!message.isOutgoing) { + _lastContactMsgRxTime = DateTime.now(); + } // Ignore messages from self (device hearing its own broadcast) // BUT allow repeated messages (pathLength indicates it went through repeater) if (_selfPublicKey != null && @@ -3302,7 +3422,6 @@ class MeshCoreConnector extends ChangeNotifier { _appSettingsService != null) { final settings = _appSettingsService!.settings; if (settings.notificationsEnabled && settings.notifyOnNewMessage) { - // Find the contact name if (contact?.type == advTypeChat) { _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown', @@ -3313,7 +3432,9 @@ class MeshCoreConnector extends ChangeNotifier { } else if (contact?.type == advTypeRoom) { _notificationService.showMessageNotification( contactName: contact?.name ?? 'Unknown Room', - message: message.text.substring(4), + message: message.text.length > 4 + ? message.text.substring(4) + : message.text, contactId: message.senderKeyHex, badgeCount: getTotalUnreadCount(), ); @@ -3488,6 +3609,7 @@ class MeshCoreConnector extends ChangeNotifier { _notificationService.showChannelMessageNotification( channelName: label, + senderName: message.senderName, message: message.text, channelIndex: channelIndex, badgeCount: getTotalUnreadCount(), @@ -3495,14 +3617,20 @@ class MeshCoreConnector extends ChangeNotifier { } void _handleIncomingChannelMessage(Uint8List frame) { - final message = ChannelMessage.fromFrame(frame); - if (message != null && message.channelIndex != null) { + final parsed = ChannelMessage.fromFrame(frame); + if (parsed != null && parsed.channelIndex != null) { if (_shouldDropSelfChannelMessage( - message.senderName, - message.pathBytes, + parsed.senderName, + parsed.pathBytes, )) { return; } + final contentHash = _computeContentHash( + parsed.channelIndex!, + parsed.timestamp.millisecondsSinceEpoch ~/ 1000, + '${parsed.senderName}: ${parsed.text}', + ); + final message = parsed.copyWith(packetHash: contentHash); _updateContactLastMessageAtByName( message.senderName, message.timestamp, @@ -3554,6 +3682,8 @@ class MeshCoreConnector extends ChangeNotifier { return; } + final pktHash = _computePacketHash(packet.payloadType, packet.payload); + final message = ChannelMessage( senderKey: null, senderName: parsed.senderName, @@ -3561,9 +3691,10 @@ class MeshCoreConnector extends ChangeNotifier { timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000), isOutgoing: false, status: ChannelMessageStatus.sent, - pathLength: packet.isFlood ? packet.pathBytes.length : 0, + pathLength: packet.isFlood ? packet.hopCount : 0, pathBytes: packet.pathBytes, channelIndex: channel.index, + packetHash: pktHash, ); _updateContactLastMessageAtByName( @@ -3611,21 +3742,13 @@ class MeshCoreConnector extends ChangeNotifier { final retryService = _retryService; if (retryService != null && - retryService.updateMessageFromSent( - ackHash, - timeoutMs, - allowQueueFallback: false, - )) { + retryService.updateMessageFromSent(ackHash, timeoutMs)) { return; } if (_markNextPendingChannelMessageSent()) { return; } - - if (retryService != null) { - retryService.updateMessageFromSent(ackHash, timeoutMs); - } } else { // Fallback to old behavior for (var messages in _conversations.values) { @@ -4016,55 +4139,98 @@ class MeshCoreConnector extends ChangeNotifier { ReactionInfo reactionInfo, String contactPubKeyHex, ) { - // Find target message by computing hash and comparing - final targetHash = reactionInfo.targetHash; final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == contactPubKeyHex, orElse: () => null, ); final isRoomServer = contact?.type == advTypeRoom; + ReactionHelper.applyReaction( + messages: messages, + reactionInfo: reactionInfo, + // Incoming reactions in 1:1: match against outgoing messages only + shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing, + getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, + getSenderName: (msg) => + _resolveContactSenderName(msg, contact, isRoomServer == true), + getMessageText: (msg) => msg.text, + getReactions: (msg) => msg.reactions, + updateMessage: (i, reactions) { + messages[i] = messages[i].copyWith(reactions: reactions); + }, + ); + } + + void _processOutgoingContactReaction( + List messages, + ReactionInfo reactionInfo, + Contact contact, + ) { + final isRoomServer = contact.type == advTypeRoom; + + ReactionHelper.applyReaction( + messages: messages, + reactionInfo: reactionInfo, + // Outgoing reactions in 1:1: match against incoming messages + shouldSkip: (msg) => !isRoomServer && msg.isOutgoing, + getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, + getSenderName: (msg) => + _resolveContactSenderName(msg, contact, isRoomServer), + getMessageText: (msg) => msg.text, + getReactions: (msg) => msg.reactions, + updateMessage: (i, reactions) { + messages[i] = messages[i].copyWith(reactions: reactions); + }, + ); + } + + void _setReactionStatus( + String pubKeyHex, + ReactionInfo reactionInfo, + MessageStatus status, + ) { + final messages = _conversations[pubKeyHex]; + if (messages == null) return; + final contact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == pubKeyHex, + orElse: () => null, + ); + final isRoomServer = contact?.type == advTypeRoom; for (int i = messages.length - 1; i >= 0; i--) { final msg = messages[i]; - - // For 1:1 chats: contact reacts to my outgoing messages only - // For room servers: any message can be reacted to (multi-user) - if (!isRoomServer && !msg.isOutgoing) continue; - final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000; - - // For room servers, include sender name (resolve from fourByteRoomContactKey) - // For 1:1 chats, sender is implicit (null) - String? senderName; - if (isRoomServer && !msg.isOutgoing) { - final senderContact = _contacts.cast().firstWhere( - (c) => - c != null && - _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey), - orElse: () => null, - ); - senderName = senderContact?.name; - } else if (isRoomServer && msg.isOutgoing) { - senderName = selfName; - } - // For 1:1, senderName stays null - final msgHash = ReactionHelper.computeReactionHash( timestampSecs, - senderName, + _resolveContactSenderName(msg, contact, isRoomServer == true), msg.text, ); - if (msgHash == targetHash) { - final currentReactions = Map.from(msg.reactions); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - - messages[i] = msg.copyWith(reactions: currentReactions); + if (msgHash == reactionInfo.targetHash) { + final statuses = Map.from(msg.reactionStatuses); + statuses[reactionInfo.emoji] = status; + messages[i] = msg.copyWith(reactionStatuses: statuses); break; } } } + String? _resolveContactSenderName( + Message msg, + Contact? contact, + bool isRoomServer, + ) { + if (!isRoomServer) return null; + if (!msg.isOutgoing) { + final senderContact = _contacts.cast().firstWhere( + (c) => + c != null && + _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey), + orElse: () => null, + ); + return senderContact?.name; + } + return selfName; + } + _RawPacket? _parseRawPacket(Uint8List raw) { if (raw.length < 3) return null; var index = 0; @@ -4077,10 +4243,11 @@ class MeshCoreConnector extends ChangeNotifier { index += 4; } if (raw.length <= index) return null; - final pathLen = raw[index++]; - if (raw.length < index + pathLen) return null; - final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathLen)); - index += pathLen; + final pathLenRaw = raw[index++]; + final pathByteLen = _decodePathByteLen(pathLenRaw); + if (raw.length < index + pathByteLen) return null; + final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathByteLen)); + index += pathByteLen; if (raw.length <= index) return null; final payload = Uint8List.fromList(raw.sublist(index)); @@ -4089,6 +4256,7 @@ class MeshCoreConnector extends ChangeNotifier { routeType: routeType, payloadType: (header >> _phTypeShift) & _phTypeMask, payloadVer: (header >> _phVerShift) & _phVerMask, + pathLenRaw: pathLenRaw, pathBytes: pathBytes, payload: payload, ); @@ -4099,6 +4267,30 @@ class MeshCoreConnector extends ChangeNotifier { return digest[0]; } + /// Firmware-compatible packet hash: SHA256(payloadType + payload) -> first 8 bytes as hex. + String _computePacketHash(int payloadType, Uint8List payload) { + final input = Uint8List(1 + payload.length); + input[0] = payloadType; + input.setRange(1, input.length, payload); + final digest = crypto.sha256.convert(input).bytes; + return digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + /// Content-based dedup hash for sync queue messages (no raw payload available). + /// Prefixed with 'c:' to avoid collisions with packet hashes. + String _computeContentHash(int channelIdx, int timestampSecs, String fullText) { + final textBytes = utf8.encode(fullText); + final input = Uint8List(5 + textBytes.length); + input[0] = channelIdx; + input[1] = timestampSecs & 0xFF; + input[2] = (timestampSecs >> 8) & 0xFF; + input[3] = (timestampSecs >> 16) & 0xFF; + input[4] = (timestampSecs >> 24) & 0xFF; + input.setRange(5, 5 + textBytes.length, textBytes); + final digest = crypto.sha256.convert(input).bytes; + return 'c:${digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + } + Uint8List? _decryptPayload(Uint8List psk, Uint8List encrypted) { if (encrypted.length <= _cipherMacSize) return null; final mac = encrypted.sublist(0, _cipherMacSize); @@ -4146,63 +4338,6 @@ class MeshCoreConnector extends ChangeNotifier { return _ParsedText(senderName: 'Unknown', text: text); } - Uint8List _resolveOutgoingPathBytes( - Contact contact, - PathSelection? selection, - ) { - // Priority 1: Check user's path override - if (contact.pathOverride != null) { - if (contact.pathOverride! < 0) { - return Uint8List(0); // Force flood - } - return contact.pathOverrideBytes ?? Uint8List(0); - } - - // Priority 2: Check device flood mode or PathSelection flood - if (contact.pathLength < 0 || selection?.useFlood == true) { - return Uint8List(0); - } - - // Priority 3: Check PathSelection (auto-rotation) - if (selection != null && selection.pathBytes.isNotEmpty) { - return Uint8List.fromList(selection.pathBytes); - } - - // Priority 4: Use device's discovered path - return contact.path; - } - - int? _resolveOutgoingPathLength(Contact contact, PathSelection? selection) { - // Priority 1: Check user's path override - if (contact.pathOverride != null) { - return contact.pathOverride; - } - - // Priority 2: Check device flood mode or PathSelection flood - if (contact.pathLength < 0 || selection?.useFlood == true) { - return -1; - } - - // Priority 3: Check PathSelection (auto-rotation) - if (selection != null && selection.pathBytes.isNotEmpty) { - return selection.hopCount; - } - - // Priority 4: Use device's discovered path - return contact.pathLength; - } - - PathSelection _selectionFromPath(int pathLength, Uint8List pathBytes) { - if (pathLength < 0) { - return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); - } - return PathSelection( - pathBytes: pathBytes, - hopCount: pathLength, - useFlood: false, - ); - } - bool _addChannelMessage(int channelIndex, ChannelMessage message) { _channelMessages.putIfAbsent(channelIndex, () => []); final messages = _channelMessages[channelIndex]!; @@ -4292,6 +4427,7 @@ class MeshCoreConnector extends ChangeNotifier { pathLength: mergedPathLength, pathBytes: mergedPathBytes, pathVariants: mergedPathVariants, + packetHash: existing.packetHash ?? processedMessage.packetHash, // Mark as sent when first repeat is heard status: promotedFromPending ? ChannelMessageStatus.sent @@ -4326,35 +4462,38 @@ class MeshCoreConnector extends ChangeNotifier { List messages, ReactionInfo reactionInfo, ) { - // Find target message by computing hash and comparing - final targetHash = reactionInfo.targetHash; - for (int i = messages.length - 1; i >= 0; i--) { - final msg = messages[i]; - final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000; - final msgHash = ReactionHelper.computeReactionHash( - timestampSecs, - msg.senderName, - msg.text, - ); - if (msgHash == targetHash) { - final currentReactions = Map.from(msg.reactions); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - - messages[i] = msg.copyWith(reactions: currentReactions); + ReactionHelper.applyReaction( + messages: messages, + reactionInfo: reactionInfo, + shouldSkip: (_) => false, + getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, + getSenderName: (msg) => msg.senderName, + getMessageText: (msg) => msg.text, + getReactions: (msg) => msg.reactions, + updateMessage: (i, reactions) { + messages[i] = messages[i].copyWith(reactions: reactions); notifyListeners(); - break; - } - } + }, + ); } int _findChannelRepeatIndex( List messages, ChannelMessage incoming, ) { + // First pass: match by packet hash (exact dedup) + final incomingHash = incoming.packetHash; + if (incomingHash != null) { + for (int i = messages.length - 1; i >= 0; i--) { + final existingHash = messages[i].packetHash; + if (existingHash != null && existingHash == incomingHash) { + return i; + } + } + } + // Second pass: heuristic fallback (outgoing echo, old messages without hash) for (int i = messages.length - 1; i >= 0; i--) { - final existing = messages[i]; - if (_isChannelRepeat(existing, incoming)) { + if (_isChannelRepeat(messages[i], incoming)) { return i; } } @@ -4368,7 +4507,7 @@ class MeshCoreConnector extends ChangeNotifier { (existing.timestamp.millisecondsSinceEpoch - incoming.timestamp.millisecondsSinceEpoch) .abs(); - if (diffMs > 5000) return false; + if (diffMs > 30000) return false; if (existing.senderName == incoming.senderName) return true; @@ -4613,8 +4752,9 @@ class MeshCoreConnector extends ChangeNotifier { packet.skipBytes(4); // Skip transport-specific bytes } //final payloadVer = (header >> 6) & 0x03; - final pathLen = packet.readByte(); - final pathBytes = packet.readBytes(pathLen); + final pathLenRaw = packet.readByte(); + final pathByteLen = _decodePathByteLen(pathLenRaw); + final pathBytes = packet.readBytes(pathByteLen); final payload = packet.readBytes(packet.remaining); final rawPacket = frame.sublist(3); @@ -4652,8 +4792,9 @@ class MeshCoreConnector extends ChangeNotifier { packet.skipBytes(4); // Skip transport-specific bytes } //final payloadVer = (header >> 6) & 0x03; - final pathLen = packet.readByte(); - pathBytes = packet.readBytes(pathLen); + final pathLenRaw = packet.readByte(); + final pathByteLen = _decodePathByteLen(pathLenRaw); + pathBytes = packet.readBytes(pathByteLen); } catch (e) { appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); return; @@ -4990,11 +5131,20 @@ const int _routeTransportDirect = 0x03; const int _payloadTypeGroupText = 0x05; const int _cipherMacSize = 2; +/// Decodes the firmware's encoded path_len byte into actual byte length. +/// Bits 0-5: hash count (0-63), Bits 6-7: hash size code (0=1byte, 1=2bytes, 2=3bytes). +int _decodePathByteLen(int pathLenRaw) { + final hashCount = pathLenRaw & 63; + final hashSize = ((pathLenRaw >> 6) & 0x03) + 1; + return hashCount * hashSize; +} + class _RawPacket { final int header; final int routeType; final int payloadType; final int payloadVer; + final int pathLenRaw; final Uint8List pathBytes; final Uint8List payload; @@ -5003,12 +5153,15 @@ class _RawPacket { required this.routeType, required this.payloadType, required this.payloadVer, + required this.pathLenRaw, required this.pathBytes, required this.payload, }); bool get isFlood => routeType == _routeFlood || routeType == _routeTransportFlood; + + int get hopCount => pathLenRaw & 63; } class _ParsedText { diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index dc9a9f5..1a0ada1 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -509,7 +509,7 @@ Uint8List buildSendTextMsgFrame( final writer = BufferWriter(); writer.writeByte(cmdSendTxtMsg); writer.writeByte(txtTypePlain); - writer.writeByte(attempt.clamp(0, 3)); + writer.writeByte(attempt.clamp(0, 255)); writer.writeUInt32LE(timestamp); writer.writeBytes(recipientPubKey.sublist(0, 6)); writer.writeString(text); @@ -838,7 +838,7 @@ Uint8List buildSendCliCommandFrame( final writer = BufferWriter(); writer.writeByte(cmdSendTxtMsg); writer.writeByte(txtTypeCliData); - writer.writeByte(attempt.clamp(0, 3)); + writer.writeByte(attempt.clamp(0, 255)); writer.writeUInt32LE(timestamp); writer.writeBytes(repeaterPubKey.sublist(0, 6)); writer.writeString(command); diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index 7a032ef..57a5e59 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -1,8 +1,47 @@ import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/l10n.dart'; +import '../utils/platform_info.dart'; class LinkHandler { + /// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile. + static Widget buildLinkifyText({ + required BuildContext context, + required String text, + required TextStyle style, + TextStyle? linkStyle, + }) { + final effectiveLinkStyle = + linkStyle ?? + style.copyWith( + color: Colors.green, + decoration: TextDecoration.underline, + ); + const options = LinkifyOptions(humanize: false, defaultToHttps: false); + const linkifiers = [UrlLinkifier()]; + void onOpen(LinkableElement link) => handleLinkTap(context, link.url); + + if (PlatformInfo.isDesktop) { + return SelectableLinkify( + text: text, + style: style, + linkStyle: effectiveLinkStyle, + options: options, + linkifiers: linkifiers, + onOpen: onOpen, + ); + } + return Linkify( + text: text, + style: style, + linkStyle: effectiveLinkStyle, + options: options, + linkifiers: linkifiers, + onOpen: onOpen, + ); + } + static Future handleLinkTap(BuildContext context, String url) async { // Show confirmation dialog final shouldOpen = await showDialog( diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart new file mode 100644 index 0000000..fe51d63 --- /dev/null +++ b/lib/helpers/path_helper.dart @@ -0,0 +1,31 @@ +import '../models/contact.dart'; +import '../connector/meshcore_protocol.dart'; + +class PathHelper { + static String formatPathHex(List pathBytes) { + return pathBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(','); + } + + static String resolvePathNames( + List pathBytes, + List allContacts, + ) { + return pathBytes + .map((b) { + final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase(); + final matches = allContacts + .where( + (c) => + c.publicKey.first == b && + (c.type == advTypeRepeater || c.type == advTypeRoom), + ) + .toList(); + if (matches.isEmpty) return hex; + if (matches.length == 1) return matches.first.name; + return matches.map((c) => c.name).join(' | '); + }) + .join(' \u2192 '); + } +} diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 88138d6..90733c3 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -8,6 +8,50 @@ class ReactionInfo { } class ReactionHelper { + /// Apply a reaction to a list of messages by matching the reaction hash. + /// + /// [messages] - the message list to search + /// [reactionInfo] - the parsed reaction + /// [getTimestampSecs] - extract timestamp seconds from a message + /// [getSenderName] - extract sender name for hash (null for 1:1 implicit) + /// [getMessageText] - extract message text + /// [getReactions] - extract current reactions map + /// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions) + /// [updateMessage] - callback to update the message at index with new reactions + /// + /// Returns whether a match was found. + static bool applyReaction({ + required List messages, + required ReactionInfo reactionInfo, + required int Function(T) getTimestampSecs, + required String? Function(T) getSenderName, + required String Function(T) getMessageText, + required Map Function(T) getReactions, + required bool Function(T) shouldSkip, + required void Function(int index, Map newReactions) + updateMessage, + }) { + final targetHash = reactionInfo.targetHash; + for (int i = messages.length - 1; i >= 0; i--) { + final msg = messages[i]; + if (shouldSkip(msg)) continue; + + final msgHash = computeReactionHash( + getTimestampSecs(msg), + getSenderName(msg), + getMessageText(msg), + ); + if (msgHash == targetHash) { + final currentReactions = Map.from(getReactions(msg)); + currentReactions[reactionInfo.emoji] = + (currentReactions[reactionInfo.emoji] ?? 0) + 1; + updateMessage(i, currentReactions); + return true; + } + } + return false; + } + static List? _cachedEmojis; /// Combined list of all reaction emojis in fixed order. diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index ca27f8c..b8ea08f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", "map_showDiscoveryContacts": "Покажи контакти за откриване", - "map_setAsMyLocation": "Задайте като моя местоположение" + "map_setAsMyLocation": "Задайте като моя местоположение", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Първоначална тежест на маршрута", + "appSettings_maxRouteWeight": "Максимално допустимо тегло на маршрута", + "appSettings_initialRouteWeightSubtitle": "Начално тегло за новооткрити маршрути", + "appSettings_maxRouteWeightSubtitle": "Максималното тегло, което един маршрут може да събере от успешни доставки.", + "appSettings_routeWeightSuccessIncrement": "Увеличение на теглото за успех", + "appSettings_routeWeightSuccessIncrementSubtitle": "Тегло, добавено към път след успешно доставяне.", + "appSettings_routeWeightFailureDecrement": "Намаляване на теглото, свързано с неуспех", + "appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.", + "appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение", + "appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bd4aed5..681cff6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1917,5 +1917,26 @@ "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}", "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen", - "map_setAsMyLocation": "Als meine aktuelle Position festlegen" + "map_setAsMyLocation": "Als meine aktuelle Position festlegen", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeightSubtitle": "Ausgangsgewicht für neu entdeckte Pfade", + "appSettings_maxRouteWeightSubtitle": "Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.", + "appSettings_maxRouteWeight": "Maximale Gesamtstreckenlänge", + "appSettings_initialRouteWeight": "Anfangs-Streckengewicht", + "appSettings_routeWeightSuccessIncrement": "Erhöhung des Erfolgsgewichts", + "appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.", + "appSettings_routeWeightFailureDecrement": "Reduzierung des Gewichts bei Fehlern", + "appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde", + "appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen", + "appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5c95e60..3942afb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -269,6 +269,23 @@ "appSettings_autoRouteRotationSubtitle": "Cycle between best paths and flood mode", "appSettings_autoRouteRotationEnabled": "Auto route rotation enabled", "appSettings_autoRouteRotationDisabled": "Auto route rotation disabled", + "appSettings_maxRouteWeight": "Max Route Weight", + "appSettings_maxRouteWeightSubtitle": "Maximum weight a path can accumulate from successful deliveries", + "appSettings_initialRouteWeight": "Initial Route Weight", + "appSettings_initialRouteWeightSubtitle": "Starting weight for newly discovered paths", + "appSettings_routeWeightSuccessIncrement": "Success Weight Increment", + "appSettings_routeWeightSuccessIncrementSubtitle": "Weight added to a path after successful delivery", + "appSettings_routeWeightFailureDecrement": "Failure Weight Decrement", + "appSettings_routeWeightFailureDecrementSubtitle": "Weight removed from a path after failed delivery", + "appSettings_maxMessageRetries": "Max Message Retries", + "appSettings_maxMessageRetriesSubtitle": "Number of retry attempts before marking a message as failed", + "path_routeWeight": "{weight}/{max}", + "@path_routeWeight": { + "placeholders": { + "weight": { "type": "String" }, + "max": { "type": "String" } + } + }, "appSettings_battery": "Battery", "appSettings_batteryChemistry": "Battery Chemistry", "appSettings_batteryChemistryPerDevice": "Set per device ({deviceName})", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 085b0c8..4a83680 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1917,5 +1917,26 @@ "tcpErrorTimedOut": "La conexión TCP ha caducado.", "tcpConnectionFailed": "Error en la conexión TCP: {error}", "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento", - "map_setAsMyLocation": "Establecer mi ubicación" + "map_setAsMyLocation": "Establecer mi ubicación", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Peso inicial de la ruta", + "appSettings_maxRouteWeight": "Peso máximo permitido para la ruta", + "appSettings_initialRouteWeightSubtitle": "Peso inicial para rutas recién descubiertas", + "appSettings_maxRouteWeightSubtitle": "Peso máximo que una ruta puede acumular gracias a entregas exitosas.", + "appSettings_routeWeightSuccessIncrement": "Incremento de peso para el éxito", + "appSettings_routeWeightSuccessIncrementSubtitle": "Peso añadido a una ruta después de una entrega exitosa.", + "appSettings_routeWeightFailureDecrement": "Reducción del peso asociado al fallo", + "appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.", + "appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes", + "appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b7617bb..1d684bb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "La connexion TCP a expiré.", "tcpConnectionFailed": "Échec de la connexion TCP : {error}", "map_showDiscoveryContacts": "Afficher les contacts de découverte", - "map_setAsMyLocation": "Définir comme ma localisation" + "map_setAsMyLocation": "Définir comme ma localisation", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeightSubtitle": "Poids maximal qu'un itinéraire peut accumuler grâce à des livraisons réussies.", + "appSettings_initialRouteWeight": "Poids initial de l'itinéraire", + "appSettings_maxRouteWeight": "Poids maximal autorisé pour le trajet", + "appSettings_initialRouteWeightSubtitle": "Poids de départ pour les nouveaux chemins découverts", + "appSettings_routeWeightSuccessIncrement": "Augmentation du poids de réussite", + "appSettings_routeWeightSuccessIncrementSubtitle": "Poids ajouté à un itinéraire après une livraison réussie.", + "appSettings_routeWeightFailureDecrement": "Réduction du poids de pénalité", + "appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.", + "appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages", + "appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 728eaac..55a1054 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "La connessione TCP è scaduta.", "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}", "map_showDiscoveryContacts": "Mostra Contatti di Discovery", - "map_setAsMyLocation": "Imposta come la mia posizione" + "map_setAsMyLocation": "Imposta come la mia posizione", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Peso iniziale del percorso", + "appSettings_initialRouteWeightSubtitle": "Peso di partenza per nuovi percorsi", + "appSettings_maxRouteWeightSubtitle": "Il peso massimo che un percorso può accumulare grazie a consegne di successo.", + "appSettings_maxRouteWeight": "Massimo peso consentito per il percorso", + "appSettings_routeWeightSuccessIncrement": "Aumento del peso del successo", + "appSettings_routeWeightSuccessIncrementSubtitle": "Peso aggiunto a un percorso dopo una consegna riuscita.", + "appSettings_routeWeightFailureDecrement": "Riduzione del peso associato al fallimento", + "appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.", + "appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio", + "appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b38c08f..84b5432 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1360,6 +1360,72 @@ abstract class AppLocalizations { /// **'Auto route rotation disabled'** String get appSettings_autoRouteRotationDisabled; + /// No description provided for @appSettings_maxRouteWeight. + /// + /// In en, this message translates to: + /// **'Max Route Weight'** + String get appSettings_maxRouteWeight; + + /// No description provided for @appSettings_maxRouteWeightSubtitle. + /// + /// In en, this message translates to: + /// **'Maximum weight a path can accumulate from successful deliveries'** + String get appSettings_maxRouteWeightSubtitle; + + /// No description provided for @appSettings_initialRouteWeight. + /// + /// In en, this message translates to: + /// **'Initial Route Weight'** + String get appSettings_initialRouteWeight; + + /// No description provided for @appSettings_initialRouteWeightSubtitle. + /// + /// In en, this message translates to: + /// **'Starting weight for newly discovered paths'** + String get appSettings_initialRouteWeightSubtitle; + + /// No description provided for @appSettings_routeWeightSuccessIncrement. + /// + /// In en, this message translates to: + /// **'Success Weight Increment'** + String get appSettings_routeWeightSuccessIncrement; + + /// No description provided for @appSettings_routeWeightSuccessIncrementSubtitle. + /// + /// In en, this message translates to: + /// **'Weight added to a path after successful delivery'** + String get appSettings_routeWeightSuccessIncrementSubtitle; + + /// No description provided for @appSettings_routeWeightFailureDecrement. + /// + /// In en, this message translates to: + /// **'Failure Weight Decrement'** + String get appSettings_routeWeightFailureDecrement; + + /// No description provided for @appSettings_routeWeightFailureDecrementSubtitle. + /// + /// In en, this message translates to: + /// **'Weight removed from a path after failed delivery'** + String get appSettings_routeWeightFailureDecrementSubtitle; + + /// No description provided for @appSettings_maxMessageRetries. + /// + /// In en, this message translates to: + /// **'Max Message Retries'** + String get appSettings_maxMessageRetries; + + /// No description provided for @appSettings_maxMessageRetriesSubtitle. + /// + /// In en, this message translates to: + /// **'Number of retry attempts before marking a message as failed'** + String get appSettings_maxMessageRetriesSubtitle; + + /// No description provided for @path_routeWeight. + /// + /// In en, this message translates to: + /// **'{weight}/{max}'** + String path_routeWeight(String weight, String max); + /// No description provided for @appSettings_battery. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 96b67d8..2821617 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -695,6 +695,51 @@ class AppLocalizationsBg extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Автоматично маршрутизирането е деактивирано'; + @override + String get appSettings_maxRouteWeight => + 'Максимално допустимо тегло на маршрута'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Максималното тегло, което един маршрут може да събере от успешни доставки.'; + + @override + String get appSettings_initialRouteWeight => + 'Първоначална тежест на маршрута'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Начално тегло за новооткрити маршрути'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Увеличение на теглото за успех'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Тегло, добавено към път след успешно доставяне.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Намаляване на теглото, свързано с неуспех'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Тегло, което е било премахнато от пътя след неуспешен опит за доставка.'; + + @override + String get appSettings_maxMessageRetries => + 'Максимален брой опити за изпращане на съобщение'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Батерия'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index dcbcd3f..337915e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -695,6 +695,49 @@ class AppLocalizationsDe extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Automatische Routenrotation deaktiviert'; + @override + String get appSettings_maxRouteWeight => 'Maximale Gesamtstreckenlänge'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Maximales Gewicht, das ein Weg durch erfolgreiche Lieferungen erreichen kann.'; + + @override + String get appSettings_initialRouteWeight => 'Anfangs-Streckengewicht'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Ausgangsgewicht für neu entdeckte Pfade'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Erhöhung des Erfolgsgewichts'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Gewicht, das einem Pfad nach erfolgreicher Lieferung hinzugefügt wird.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Reduzierung des Gewichts bei Fehlern'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde'; + + @override + String get appSettings_maxMessageRetries => + 'Maximale Anzahl an Wiederholungsversuchen'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Akku'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 01127c6..1e4e5b0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -684,6 +684,48 @@ class AppLocalizationsEn extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Auto route rotation disabled'; + @override + String get appSettings_maxRouteWeight => 'Max Route Weight'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Maximum weight a path can accumulate from successful deliveries'; + + @override + String get appSettings_initialRouteWeight => 'Initial Route Weight'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Starting weight for newly discovered paths'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Success Weight Increment'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Weight added to a path after successful delivery'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Failure Weight Decrement'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Weight removed from a path after failed delivery'; + + @override + String get appSettings_maxMessageRetries => 'Max Message Retries'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Number of retry attempts before marking a message as failed'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Battery'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index fac431e..657d556 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -694,6 +694,49 @@ class AppLocalizationsEs extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Rotación de ruta automática desactivada'; + @override + String get appSettings_maxRouteWeight => 'Peso máximo permitido para la ruta'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Peso máximo que una ruta puede acumular gracias a entregas exitosas.'; + + @override + String get appSettings_initialRouteWeight => 'Peso inicial de la ruta'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Peso inicial para rutas recién descubiertas'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Incremento de peso para el éxito'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Peso añadido a una ruta después de una entrega exitosa.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Reducción del peso asociado al fallo'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Peso retirado de un camino después de un intento de entrega fallido.'; + + @override + String get appSettings_maxMessageRetries => + 'Número máximo de reintentos de envío de mensajes'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Número de intentos de reintento antes de marcar un mensaje como fallido.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batería'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6932437..7aa7ebe 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -698,6 +698,50 @@ class AppLocalizationsFr extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Rotation de l\'itinéraire automatique désactivée'; + @override + String get appSettings_maxRouteWeight => + 'Poids maximal autorisé pour le trajet'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Poids maximal qu\'un itinéraire peut accumuler grâce à des livraisons réussies.'; + + @override + String get appSettings_initialRouteWeight => 'Poids initial de l\'itinéraire'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Poids de départ pour les nouveaux chemins découverts'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Augmentation du poids de réussite'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Poids ajouté à un itinéraire après une livraison réussie.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Réduction du poids de pénalité'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Poids retiré d\'un itinéraire après une tentative de livraison infructueuse.'; + + @override + String get appSettings_maxMessageRetries => + 'Nombre maximal de tentatives de récupération de messages'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Nombre de tentatives de relance avant de marquer un message comme ayant échoué.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batterie'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 68c2af3..02c5937 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -695,6 +695,50 @@ class AppLocalizationsIt extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Rotazione del percorso automatico disabilitata'; + @override + String get appSettings_maxRouteWeight => + 'Massimo peso consentito per il percorso'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Il peso massimo che un percorso può accumulare grazie a consegne di successo.'; + + @override + String get appSettings_initialRouteWeight => 'Peso iniziale del percorso'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Peso di partenza per nuovi percorsi'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Aumento del peso del successo'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Peso aggiunto a un percorso dopo una consegna riuscita.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Riduzione del peso associato al fallimento'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Peso rimosso da un percorso dopo un tentativo di consegna fallito.'; + + @override + String get appSettings_maxMessageRetries => + 'Numero massimo di tentativi di invio del messaggio'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Numero di tentativi di riprova prima di considerare un messaggio come fallito.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batteria'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 4031ddf..9e51164 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -689,6 +689,49 @@ class AppLocalizationsNl extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Automatische route rotatie is uitgeschakeld'; + @override + String get appSettings_maxRouteWeight => 'Maximale gewicht voor de route'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.'; + + @override + String get appSettings_initialRouteWeight => 'เริ่มต้น gewicht van de route'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Startgewicht voor nieuwe, ontdekte routes'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Toename in het gewicht van het succes'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Gewicht wordt toegevoegd aan een route na een succesvolle levering.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Vermindering van het gewicht van fouten'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Gewicht verwijderd van een pad na een mislukte levering'; + + @override + String get appSettings_maxMessageRetries => + 'Aantal pogingen om berichten te versturen'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batterij'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 6378e74..176c17e 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -698,6 +698,49 @@ class AppLocalizationsPl extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Automatyczne obracanie tras wyłączone'; + @override + String get appSettings_maxRouteWeight => + 'Maksymalny dopuszczalny ciężar pojazdu'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.'; + + @override + String get appSettings_initialRouteWeight => 'Początkowa waga trasy'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Początkowa waga dla nowych, odkrytych ścieżek'; + + @override + String get appSettings_routeWeightSuccessIncrement => 'Wzrost wagi sukcesu'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Waga dodana do ścieżki po pomyślnym dostarczeniu'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Zmniejszenie wagi kary'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Waga usunięta z trasy po nieudanej dostawie'; + + @override + String get appSettings_maxMessageRetries => + 'Maksymalna liczba prób wysłania wiadomości'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Bateria'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 908ad96..a51e1b0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -696,6 +696,49 @@ class AppLocalizationsPt extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Rotação de roteamento automático desativada'; + @override + String get appSettings_maxRouteWeight => 'Peso Máximo da Rota'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.'; + + @override + String get appSettings_initialRouteWeight => 'Peso Inicial da Rota'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Peso inicial para novos caminhos descobertos'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Aumento do peso para indicar sucesso'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Peso adicionado a um caminho após a entrega bem-sucedida.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Redução do peso da falha'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Peso removido de um caminho após uma tentativa de entrega malsucedida.'; + + @override + String get appSettings_maxMessageRetries => + 'Número máximo de tentativas de envio de mensagens'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Número de tentativas de reenvio antes de classificar uma mensagem como falha.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Bateria'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 67011fb..7a6998f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -696,6 +696,50 @@ class AppLocalizationsRu extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Автоматическое переключение маршрутов отключено'; + @override + String get appSettings_maxRouteWeight => + 'Максимальный допустимый вес маршрута'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.'; + + @override + String get appSettings_initialRouteWeight => 'Начальный вес маршрута'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Начальный вес для новых, только что открытых маршрутов'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Увеличение веса успеха'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Вес, добавленный к маршруту после успешной доставки.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Уменьшение веса неудачи'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Вес, который был удален с пути после неудачной доставки.'; + + @override + String get appSettings_maxMessageRetries => + 'Максимальное количество повторных попыток отправки сообщения'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Батарея'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 4f033f9..ae6c956 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -687,6 +687,48 @@ class AppLocalizationsSk extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Automatické prekladanie trás pozastavené'; + @override + String get appSettings_maxRouteWeight => 'Maximálna hmotnosť trasy'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.'; + + @override + String get appSettings_initialRouteWeight => 'Počiatočná váha trasy'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Počiatočná váha pre nové, objavené cesty'; + + @override + String get appSettings_routeWeightSuccessIncrement => 'Zvyšenie váhy úspechu'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Hmotnosť pridaná k trase po úspešnej doručení'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Sníženie váhy, ktorá sa používa na odhad rizika.'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Hmotnosť odstránená z cesty po neúspešnej doručenie'; + + @override + String get appSettings_maxMessageRetries => + 'Maximalný počet pokusov o doručenie správ'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Počet pokusov o odošleť pred označením správy ako neúspešnej'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batéria'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e7c48f6..96501cd 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -687,6 +687,49 @@ class AppLocalizationsSl extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Samodejno krmilno rotiranje je onemogočeno'; + @override + String get appSettings_maxRouteWeight => 'Največja dovoljena teža poti'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.'; + + @override + String get appSettings_initialRouteWeight => 'Izvirna teža poti'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Izguba teže za nove, odkriti poti'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Učinkovitost: povečanje'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Težava, dodana poti po uspešni dostavi'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Zmanjšanje teže, ki je povezana s pomanjkanjem'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Težo, ki ni bila uspešno dostavljena, odstranili s poti.'; + + @override + String get appSettings_maxMessageRetries => + 'Najve število poskusov pošiljanja sporočil'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Baterija'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 6ccea2f..a834230 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -682,6 +682,48 @@ class AppLocalizationsSv extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Automatisk ruttrotation är avstängd'; + @override + String get appSettings_maxRouteWeight => 'Maximalt tillåtet vikt för rutten'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.'; + + @override + String get appSettings_initialRouteWeight => 'Initial vikt för rutt'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Initial vikt för nyligen upptäckta vägar'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Ökning av vikt för framgång'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Vikt läggs till en väg efter en lyckad leverans.'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Minskning av vikten för misslyckande'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Vikt som tagits bort från en väg efter ett misslyckat leveransförsök'; + + @override + String get appSettings_maxMessageRetries => 'Maximalt antal försök'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Antal försök att skicka om ett meddelande innan det markeras som misslyckat.'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Batteri'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 788c9d1..7db1cc7 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -692,6 +692,49 @@ class AppLocalizationsUk extends AppLocalizations { String get appSettings_autoRouteRotationDisabled => 'Авторотація маршрутизації вимкнена'; + @override + String get appSettings_maxRouteWeight => 'Максимальна вага маршруту'; + + @override + String get appSettings_maxRouteWeightSubtitle => + 'Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.'; + + @override + String get appSettings_initialRouteWeight => 'Початкова вартість маршруту'; + + @override + String get appSettings_initialRouteWeightSubtitle => + 'Початкова вага для нових відкритих шляхів'; + + @override + String get appSettings_routeWeightSuccessIncrement => + 'Збільшення ваги успіху'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + 'Вага, додана до маршруту після успішної доставки'; + + @override + String get appSettings_routeWeightFailureDecrement => + 'Зменшення ваги помилки'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + 'Вага, яка була знята з маршруту після невдалої доставки'; + + @override + String get appSettings_maxMessageRetries => + 'Максимальна кількість повторних спроб надсилання повідомлення'; + + @override + String get appSettings_maxMessageRetriesSubtitle => + 'Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => 'Батарея'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index be7eeb0..dc1a17e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -648,6 +648,43 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_autoRouteRotationDisabled => '自动路径轮换已禁用'; + @override + String get appSettings_maxRouteWeight => '最大路径重量'; + + @override + String get appSettings_maxRouteWeightSubtitle => '一条路径可以累积的最大重量,取决于成功交付的数量。'; + + @override + String get appSettings_initialRouteWeight => '初始路线权重'; + + @override + String get appSettings_initialRouteWeightSubtitle => '新发现路径的初始重量'; + + @override + String get appSettings_routeWeightSuccessIncrement => '成功权重增加'; + + @override + String get appSettings_routeWeightSuccessIncrementSubtitle => + '在成功交付后,将重量添加到路径中'; + + @override + String get appSettings_routeWeightFailureDecrement => '失败权重降低'; + + @override + String get appSettings_routeWeightFailureDecrementSubtitle => + '从一条路径上移除的货物,由于无法成功交付而移除。'; + + @override + String get appSettings_maxMessageRetries => '最大消息重试次数'; + + @override + String get appSettings_maxMessageRetriesSubtitle => '在将消息标记为失败之前,允许尝试的次数'; + + @override + String path_routeWeight(String weight, String max) { + return '$weight/$max'; + } + @override String get appSettings_battery => '电池'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 648d711..3caea31 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "De TCP-verbinding is verlopen.", "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}", "map_showDiscoveryContacts": "Ontdek contacten weergeven", - "map_setAsMyLocation": "Stel dit in als mijn locatie" + "map_setAsMyLocation": "Stel dit in als mijn locatie", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeightSubtitle": "Het maximale gewicht dat een route kan bereiken door succesvolle leveringen.", + "appSettings_initialRouteWeight": "เริ่มต้น gewicht van de route", + "appSettings_maxRouteWeight": "Maximale gewicht voor de route", + "appSettings_initialRouteWeightSubtitle": "Startgewicht voor nieuwe, ontdekte routes", + "appSettings_routeWeightSuccessIncrement": "Toename in het gewicht van het succes", + "appSettings_routeWeightSuccessIncrementSubtitle": "Gewicht wordt toegevoegd aan een route na een succesvolle levering.", + "appSettings_routeWeightFailureDecrement": "Vermindering van het gewicht van fouten", + "appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering", + "appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen", + "appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f4f3ac7..c6e3fc4 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", "tcpConnectionFailed": "Błąd połączenia TCP: {error}", "map_showDiscoveryContacts": "Pokaż kontakty odkrywania", - "map_setAsMyLocation": "Ustaw jako moje lokalizację" + "map_setAsMyLocation": "Ustaw jako moje lokalizację", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Początkowa waga trasy", + "appSettings_maxRouteWeight": "Maksymalny dopuszczalny ciężar pojazdu", + "appSettings_initialRouteWeightSubtitle": "Początkowa waga dla nowych, odkrytych ścieżek", + "appSettings_maxRouteWeightSubtitle": "Maksymalna waga, jaką ścieżka może zgromadzić dzięki udanym dostawom.", + "appSettings_routeWeightSuccessIncrement": "Wzrost wagi sukcesu", + "appSettings_routeWeightSuccessIncrementSubtitle": "Waga dodana do ścieżki po pomyślnym dostarczeniu", + "appSettings_routeWeightFailureDecrement": "Zmniejszenie wagi kary", + "appSettings_routeWeightFailureDecrementSubtitle": "Waga usunięta z trasy po nieudanej dostawie", + "appSettings_maxMessageRetries": "Maksymalna liczba prób wysłania wiadomości", + "appSettings_maxMessageRetriesSubtitle": "Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index dd1698c..e7e2ec6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "A conexão TCP expirou.", "tcpConnectionFailed": "Falha na conexão TCP: {error}", "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta", - "map_setAsMyLocation": "Defina minha localização" + "map_setAsMyLocation": "Defina minha localização", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Peso Inicial da Rota", + "appSettings_maxRouteWeight": "Peso Máximo da Rota", + "appSettings_maxRouteWeightSubtitle": "Peso máximo que um determinado percurso pode acumular com entregas bem-sucedidas.", + "appSettings_initialRouteWeightSubtitle": "Peso inicial para novos caminhos descobertos", + "appSettings_routeWeightSuccessIncrement": "Aumento do peso para indicar sucesso", + "appSettings_routeWeightSuccessIncrementSubtitle": "Peso adicionado a um caminho após a entrega bem-sucedida.", + "appSettings_routeWeightFailureDecrement": "Redução do peso da falha", + "appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.", + "appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens", + "appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ea75aca..92a3800 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1129,5 +1129,26 @@ "tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", "map_showDiscoveryContacts": "Показать контакты Discovery", - "map_setAsMyLocation": "Установить мое местоположение" + "map_setAsMyLocation": "Установить мое местоположение", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута", + "appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.", + "appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов", + "appSettings_initialRouteWeight": "Начальный вес маршрута", + "appSettings_routeWeightSuccessIncrement": "Увеличение веса успеха", + "appSettings_routeWeightSuccessIncrementSubtitle": "Вес, добавленный к маршруту после успешной доставки.", + "appSettings_routeWeightFailureDecrement": "Уменьшение веса неудачи", + "appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.", + "appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения", + "appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 636556e..75a7c7d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "Pripojenie TCP vypršalo.", "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}", "map_showDiscoveryContacts": "Zobraziť kontakty objavov", - "map_setAsMyLocation": "Nastavte ako moju polohu" + "map_setAsMyLocation": "Nastavte ako moju polohu", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeightSubtitle": "Maximálna hmotnosť, ktorú môže trás prenášať vďaka úspešným zásielkam.", + "appSettings_initialRouteWeightSubtitle": "Počiatočná váha pre nové, objavené cesty", + "appSettings_initialRouteWeight": "Počiatočná váha trasy", + "appSettings_maxRouteWeight": "Maximálna hmotnosť trasy", + "appSettings_routeWeightSuccessIncrement": "Zvyšenie váhy úspechu", + "appSettings_routeWeightSuccessIncrementSubtitle": "Hmotnosť pridaná k trase po úspešnej doručení", + "appSettings_routeWeightFailureDecrement": "Sníženie váhy, ktorá sa používa na odhad rizika.", + "appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie", + "appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ", + "appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index dfc5a69..5ab4736 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}", "map_showDiscoveryContacts": "Prikaži odkritja kontaktov", - "map_setAsMyLocation": "Nastavite to kot mojo lokacijo" + "map_setAsMyLocation": "Nastavite to kot mojo lokacijo", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeightSubtitle": "Največja teža, ki jo lahko pot doseže s uspešnimi dostavnami.", + "appSettings_initialRouteWeight": "Izvirna teža poti", + "appSettings_initialRouteWeightSubtitle": "Izguba teže za nove, odkriti poti", + "appSettings_maxRouteWeight": "Največja dovoljena teža poti", + "appSettings_routeWeightSuccessIncrement": "Učinkovitost: povečanje", + "appSettings_routeWeightSuccessIncrementSubtitle": "Težava, dodana poti po uspešni dostavi", + "appSettings_routeWeightFailureDecrement": "Zmanjšanje teže, ki je povezana s pomanjkanjem", + "appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.", + "appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil", + "appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 6a8d801..644b43b 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}", "map_showDiscoveryContacts": "Visa Discovery-kontakter", - "map_setAsMyLocation": "Ange som min plats" + "map_setAsMyLocation": "Ange som min plats", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeightSubtitle": "Initial vikt för nyligen upptäckta vägar", + "appSettings_maxRouteWeight": "Maximalt tillåtet vikt för rutten", + "appSettings_maxRouteWeightSubtitle": "Maximal vikt som en leveransväg kan ackumulera från framgångsrika leveranser.", + "appSettings_initialRouteWeight": "Initial vikt för rutt", + "appSettings_routeWeightSuccessIncrement": "Ökning av vikt för framgång", + "appSettings_routeWeightSuccessIncrementSubtitle": "Vikt läggs till en väg efter en lyckad leverans.", + "appSettings_routeWeightFailureDecrement": "Minskning av vikten för misslyckande", + "appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök", + "appSettings_maxMessageRetries": "Maximalt antal försök", + "appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a50bd78..249fd3b 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1889,5 +1889,26 @@ "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", "map_showDiscoveryContacts": "Показати контакти Відкриття", - "map_setAsMyLocation": "Встановити моє місцезнаходження" + "map_setAsMyLocation": "Встановити моє місцезнаходження", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_initialRouteWeight": "Початкова вартість маршруту", + "appSettings_initialRouteWeightSubtitle": "Початкова вага для нових відкритих шляхів", + "appSettings_maxRouteWeight": "Максимальна вага маршруту", + "appSettings_maxRouteWeightSubtitle": "Максимальна вага, яку може накопичити маршрут завдяки успішним доставкам.", + "appSettings_routeWeightSuccessIncrement": "Збільшення ваги успіху", + "appSettings_routeWeightSuccessIncrementSubtitle": "Вага, додана до маршруту після успішної доставки", + "appSettings_routeWeightFailureDecrement": "Зменшення ваги помилки", + "appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки", + "appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення", + "appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 54d1e3c..1d4ed30 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1894,5 +1894,26 @@ "tcpErrorTimedOut": "TCP 连接超时。", "tcpConnectionFailed": "TCP 连接失败:{error}", "map_showDiscoveryContacts": "显示发现联系人", - "map_setAsMyLocation": "设置为我的位置" + "map_setAsMyLocation": "设置为我的位置", + "@path_routeWeight": { + "placeholders": { + "weight": { + "type": "String" + }, + "max": { + "type": "String" + } + } + }, + "appSettings_maxRouteWeight": "最大路径重量", + "appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量", + "appSettings_initialRouteWeight": "初始路线权重", + "appSettings_maxRouteWeightSubtitle": "一条路径可以累积的最大重量,取决于成功交付的数量。", + "appSettings_routeWeightSuccessIncrement": "成功权重增加", + "appSettings_routeWeightSuccessIncrementSubtitle": "在成功交付后,将重量添加到路径中", + "appSettings_routeWeightFailureDecrement": "失败权重降低", + "appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。", + "appSettings_maxMessageRetries": "最大消息重试次数", + "appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数", + "path_routeWeight": "{weight}/{max}" } diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index fc84851..8ee904d 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -32,6 +32,11 @@ class AppSettings { final bool notifyOnNewChannelMessage; final bool notifyOnNewAdvert; final bool autoRouteRotationEnabled; + final double maxRouteWeight; + final double initialRouteWeight; + final double routeWeightSuccessIncrement; + final double routeWeightFailureDecrement; + final int maxMessageRetries; final String themeMode; final String? languageOverride; // null = system default final bool appDebugLogEnabled; @@ -62,6 +67,11 @@ class AppSettings { this.notifyOnNewChannelMessage = true, this.notifyOnNewAdvert = true, this.autoRouteRotationEnabled = false, + this.maxRouteWeight = 5.0, + this.initialRouteWeight = 3.0, + this.routeWeightSuccessIncrement = 0.5, + this.routeWeightFailureDecrement = 0.2, + this.maxMessageRetries = 5, this.themeMode = 'system', this.languageOverride, this.appDebugLogEnabled = false, @@ -96,6 +106,11 @@ class AppSettings { 'notify_on_new_channel_message': notifyOnNewChannelMessage, 'notify_on_new_advert': notifyOnNewAdvert, 'auto_route_rotation_enabled': autoRouteRotationEnabled, + 'max_route_weight': maxRouteWeight, + 'initial_route_weight': initialRouteWeight, + 'route_weight_success_increment': routeWeightSuccessIncrement, + 'route_weight_failure_decrement': routeWeightFailureDecrement, + 'max_message_retries': maxMessageRetries, 'theme_mode': themeMode, 'language_override': languageOverride, 'app_debug_log_enabled': appDebugLogEnabled, @@ -142,6 +157,14 @@ class AppSettings { notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, + maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0, + initialRouteWeight: + (json['initial_route_weight'] as num?)?.toDouble() ?? 3.0, + routeWeightSuccessIncrement: + (json['route_weight_success_increment'] as num?)?.toDouble() ?? 0.5, + routeWeightFailureDecrement: + (json['route_weight_failure_decrement'] as num?)?.toDouble() ?? 0.2, + maxMessageRetries: json['max_message_retries'] as int? ?? 5, themeMode: json['theme_mode'] as String? ?? 'system', languageOverride: json['language_override'] as String?, appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false, @@ -187,6 +210,11 @@ class AppSettings { bool? notifyOnNewChannelMessage, bool? notifyOnNewAdvert, bool? autoRouteRotationEnabled, + double? maxRouteWeight, + double? initialRouteWeight, + double? routeWeightSuccessIncrement, + double? routeWeightFailureDecrement, + int? maxMessageRetries, String? themeMode, Object? languageOverride = _unset, bool? appDebugLogEnabled, @@ -222,6 +250,13 @@ class AppSettings { notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, + maxRouteWeight: maxRouteWeight ?? this.maxRouteWeight, + initialRouteWeight: initialRouteWeight ?? this.initialRouteWeight, + routeWeightSuccessIncrement: + routeWeightSuccessIncrement ?? this.routeWeightSuccessIncrement, + routeWeightFailureDecrement: + routeWeightFailureDecrement ?? this.routeWeightFailureDecrement, + maxMessageRetries: maxMessageRetries ?? this.maxMessageRetries, themeMode: themeMode ?? this.themeMode, languageOverride: languageOverride == _unset ? this.languageOverride diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 2418871..b0af3eb 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -36,6 +36,7 @@ class ChannelMessage { final List pathVariants; final int? channelIndex; final String messageId; + final String? packetHash; final String? replyToMessageId; final String? replyToSenderName; final String? replyToText; @@ -55,6 +56,7 @@ class ChannelMessage { List? pathVariants, this.channelIndex, String? messageId, + this.packetHash, this.replyToMessageId, this.replyToSenderName, this.replyToText, @@ -79,6 +81,7 @@ class ChannelMessage { int? pathLength, Uint8List? pathBytes, List? pathVariants, + String? packetHash, String? replyToMessageId, String? replyToSenderName, String? replyToText, @@ -98,6 +101,7 @@ class ChannelMessage { pathVariants: pathVariants ?? this.pathVariants, channelIndex: channelIndex, messageId: messageId, + packetHash: packetHash ?? this.packetHash, replyToMessageId: replyToMessageId ?? this.replyToMessageId, replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, diff --git a/lib/models/contact.dart b/lib/models/contact.dart index c047622..71467b1 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -157,6 +157,12 @@ class Contact { return null; } final pubKey = reader.readBytes(pubKeySize); + + // Guard: reject contacts with zeroed or mostly-zeroed public keys + // (indicates corrupt flash storage on the firmware side) + final zeroCount = pubKey.where((b) => b == 0).length; + if (zeroCount > pubKeySize ~/ 2) return null; + final type = reader.readByte(); final flags = reader.readByte(); final pathLen = reader.readByte(); @@ -166,6 +172,12 @@ class Contact { final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen); final name = reader.readCStringGreedy(maxNameSize); + // Guard: reject contacts with non-printable names (corrupt flash data) + if (name.isNotEmpty && + name.codeUnits.every((c) => c < 0x20 || c == 0xFFFD)) { + return null; + } + final lastMod = reader.readUInt32LE(); double? lat, lon; @@ -182,7 +194,7 @@ class Contact { name: name.isEmpty ? 'Unknown' : name, type: type, flags: flags, - pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1, + pathLength: (pathLen == 0xFF || pathLen > maxPathSize) ? -1 : pathLen, path: pathBytes, latitude: lat, longitude: lon, diff --git a/lib/models/message.dart b/lib/models/message.dart index 4f42d96..6f6ed88 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -23,6 +23,7 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + final Map reactionStatuses; final Uint8List fourByteRoomContactKey; Message({ @@ -43,9 +44,11 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? reactions, + Map? reactionStatuses, }) : pathBytes = pathBytes ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), - reactions = reactions ?? {}; + reactions = reactions ?? {}, + reactionStatuses = reactionStatuses ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -61,6 +64,7 @@ class Message { Uint8List? pathBytes, bool? isCli, Map? reactions, + Map? reactionStatuses, Uint8List? fourByteRoomContactKey, }) { return Message( @@ -80,6 +84,7 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + reactionStatuses: reactionStatuses ?? this.reactionStatuses, fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, ); diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart index 5e3ea1f..ff2d226 100644 --- a/lib/models/path_history.dart +++ b/lib/models/path_history.dart @@ -1,11 +1,12 @@ class PathRecord { final int hopCount; final int tripTimeMs; - final DateTime timestamp; + final DateTime? timestamp; final bool wasFloodDiscovery; final List pathBytes; final int successCount; final int failureCount; + final double routeWeight; PathRecord({ required this.hopCount, @@ -15,6 +16,7 @@ class PathRecord { required this.pathBytes, required this.successCount, required this.failureCount, + this.routeWeight = 1.0, }); String get displayText => @@ -24,11 +26,12 @@ class PathRecord { return { 'hop_count': hopCount, 'trip_time_ms': tripTimeMs, - 'timestamp': timestamp.toIso8601String(), + 'timestamp': timestamp?.toIso8601String(), 'was_flood': wasFloodDiscovery, 'path_bytes': pathBytes, 'success_count': successCount, 'failure_count': failureCount, + 'route_weight': routeWeight, }; } @@ -36,12 +39,15 @@ class PathRecord { return PathRecord( hopCount: json['hop_count'] as int, tripTimeMs: json['trip_time_ms'] as int, - timestamp: DateTime.parse(json['timestamp'] as String), + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : null, wasFloodDiscovery: json['was_flood'] as bool, pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [], successCount: json['success_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0, + routeWeight: (json['route_weight'] as num?)?.toDouble() ?? 1.0, ); } } diff --git a/lib/models/path_selection.dart b/lib/models/path_selection.dart index 65f2f27..cdb3d72 100644 --- a/lib/models/path_selection.dart +++ b/lib/models/path_selection.dart @@ -1,3 +1,9 @@ +import 'dart:typed_data'; + +import 'contact.dart'; + +const int recentAttemptDiversityWindow = 2; + class PathSelection { final List pathBytes; final int hopCount; @@ -9,3 +15,38 @@ class PathSelection { required this.useFlood, }); } + +PathSelection resolvePathSelection( + Contact contact, { + PathSelection? selection, + bool forceFlood = false, +}) { + if (contact.pathOverride != null) { + if (contact.pathOverride! < 0) { + return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); + } + return PathSelection( + pathBytes: contact.pathOverrideBytes ?? Uint8List(0), + hopCount: contact.pathOverride!, + useFlood: false, + ); + } + + if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { + return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); + } + + if (selection != null && selection.pathBytes.isNotEmpty) { + return PathSelection( + pathBytes: selection.pathBytes, + hopCount: selection.hopCount, + useFlood: false, + ); + } + + return PathSelection( + pathBytes: contact.path, + hopCount: contact.pathLength, + useFlood: false, + ); +} diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index a2c920e..7e0980e 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -310,6 +310,118 @@ class AppSettingsScreen extends StatelessWidget { ); }, ), + if (settingsService.settings.autoRouteRotationEnabled) ...[ + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_maxRouteWeight), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.appSettings_maxRouteWeightSubtitle), + Slider( + value: settingsService.settings.maxRouteWeight, + min: 1, + max: 10, + divisions: 9, + label: settingsService.settings.maxRouteWeight + .round() + .toString(), + onChanged: (value) => + settingsService.setMaxRouteWeight(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_initialRouteWeight), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.appSettings_initialRouteWeightSubtitle), + Slider( + value: settingsService.settings.initialRouteWeight, + min: 0.5, + max: 5.0, + divisions: 9, + label: settingsService.settings.initialRouteWeight + .toStringAsFixed(1), + onChanged: (value) => + settingsService.setInitialRouteWeight(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_routeWeightSuccessIncrement), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_routeWeightSuccessIncrementSubtitle, + ), + Slider( + value: settingsService.settings.routeWeightSuccessIncrement, + min: 0.1, + max: 2.0, + divisions: 19, + label: settingsService.settings.routeWeightSuccessIncrement + .toStringAsFixed(1), + onChanged: (value) => + settingsService.setRouteWeightSuccessIncrement(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_routeWeightFailureDecrement), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_routeWeightFailureDecrementSubtitle, + ), + Slider( + value: settingsService.settings.routeWeightFailureDecrement, + min: 0.1, + max: 2.0, + divisions: 19, + label: settingsService.settings.routeWeightFailureDecrement + .toStringAsFixed(1), + onChanged: (value) => + settingsService.setRouteWeightFailureDecrement(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_maxMessageRetries), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.appSettings_maxMessageRetriesSubtitle), + Slider( + value: settingsService.settings.maxMessageRetries + .toDouble(), + min: 2, + max: 10, + divisions: 8, + label: settingsService.settings.maxMessageRetries + .toString(), + onChanged: (value) => + settingsService.setMaxMessageRetries(value.toInt()), + ), + ], + ), + ), + ], ], ), ); diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 4e3743d..20110e1 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -4,11 +4,11 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/link_handler.dart'; @@ -311,8 +311,13 @@ class _ChannelChatScreenState extends State { ], Flexible( child: GestureDetector( - onTap: () => _showMessagePathInfo(message), + onTap: PlatformInfo.isDesktop + ? null + : () => _showMessagePathInfo(message), onLongPress: () => _showMessageActions(message), + onSecondaryTapUp: PlatformInfo.isDesktop + ? (_) => _showMessageActions(message) + : null, child: Container( padding: gifId != null ? const EdgeInsets.all(4) @@ -430,7 +435,8 @@ class _ChannelChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Linkify( + child: LinkHandler.buildLinkifyText( + context: context, text: message.text, style: TextStyle( fontSize: bodyFontSize * textScale, @@ -440,15 +446,6 @@ class _ChannelChatScreenState extends State { color: Colors.green, decoration: TextDecoration.underline, ), - options: const LinkifyOptions( - humanize: false, - defaultToHttps: false, - ), - linkifiers: const [UrlLinkifier()], - onOpen: (link) => LinkHandler.handleLinkTap( - context, - link.url, - ), ), ), if (!enableTracing && isOutgoing) ...[ @@ -557,7 +554,7 @@ class _ChannelChatScreenState extends State { ], ); - if (!isOutgoing) { + if (!isOutgoing && !PlatformInfo.isDesktop) { return _SwipeReplyBubble( maxSwipeOffset: maxSwipeOffset, replySwipeThreshold: replySwipeThreshold, @@ -1112,6 +1109,15 @@ class _ChannelChatScreenState extends State { _setReplyingTo(message); }, ), + if (PlatformInfo.isDesktop) + ListTile( + leading: const Icon(Icons.route), + title: Text(context.l10n.chat_path), + onTap: () { + Navigator.pop(sheetContext); + _showMessagePathInfo(message); + }, + ), // Can't react to your own messages if (!message.isOutgoing) ListTile( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 98694be..51d2453 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:meshcore_open/storage/channel_message_store.dart'; +import 'package:meshcore_open/utils/platform_info.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; @@ -417,78 +418,96 @@ class _ChannelsScreenState extends State return Card( key: ValueKey('channel_${channel.index}'), margin: const EdgeInsets.only(bottom: 12), - child: ListTile( - dense: true, - minVerticalPadding: 0, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - visualDensity: const VisualDensity(vertical: -2), - leading: Stack( - children: [ - CircleAvatar( - backgroundColor: bgColor, - child: Icon(icon, color: iconColor), - ), - if (isCommunityChannel) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: Colors.purple, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).cardColor, - width: 2, + child: GestureDetector( + onSecondaryTapUp: PlatformInfo.isDesktop + ? (_) => _showChannelActions( + context, + connector, + channelMessageStore, + channel, + ) + : null, + child: ListTile( + dense: true, + minVerticalPadding: 0, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + visualDensity: const VisualDensity(vertical: -2), + leading: Stack( + children: [ + CircleAvatar( + backgroundColor: bgColor, + child: Icon(icon, color: iconColor), + ), + if (isCommunityChannel) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.purple, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).cardColor, + width: 2, + ), + ), + child: const Icon( + Icons.people, + size: 8, + color: Colors.white, ), ), - child: const Icon(Icons.people, size: 8, color: Colors.white), ), - ), - ], - ), - title: Text( - channel.name.isEmpty - ? context.l10n.channels_channelIndex(channel.index) - : channel.name, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(width: 4), ], - if (showDragHandle && dragIndex != null) - ReorderableDelayedDragStartListener( - index: dragIndex, - child: Icon( - Icons.drag_handle, - color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text( + channel.name.isEmpty + ? context.l10n.channels_channelIndex(channel.index) + : channel.name, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (unreadCount > 0) ...[ + UnreadBadge(count: unreadCount), + const SizedBox(width: 4), + ], + if (showDragHandle && dragIndex != null) + ReorderableDelayedDragStartListener( + index: dragIndex, + child: Icon( + Icons.drag_handle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), - ], - ), - onTap: () async { - connector.markChannelRead(channel.index); - await Future.delayed(const Duration(milliseconds: 50)); - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChannelChatScreen(channel: channel), - ), - ); - } - }, - onLongPress: () => _showChannelActions( - context, - connector, - channelMessageStore, - channel, + ], + ), + onTap: () async { + connector.markChannelRead(channel.index); + await Future.delayed(const Duration(milliseconds: 50)); + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChannelChatScreen(channel: channel), + ), + ); + } + }, + onLongPress: () => _showChannelActions( + context, + connector, + channelMessageStore, + channel, + ), ), ), ); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 5209b41..ace82b5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:provider/provider.dart'; + +import '../utils/platform_info.dart'; import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; @@ -16,6 +17,7 @@ import '../helpers/reaction_helper.dart'; import '../widgets/message_status_icon.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/link_handler.dart'; +import '../helpers/path_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; import '../models/contact.dart'; @@ -362,6 +364,8 @@ class _ChatScreenState extends State { textScale: textScale, onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message, contact), + onRetryReaction: (msg, emoji) => + _sendReaction(msg, contact, emoji), ); }, ); @@ -820,7 +824,8 @@ class _ChatScreenState extends State { ); } - String _formatRelativeTime(DateTime time) { + String _formatRelativeTime(DateTime? time) { + if (time == null) return '—'; final diff = DateTime.now().difference(time); if (diff.inSeconds < 60) return context.l10n.time_justNow; if (diff.inMinutes < 60) { @@ -841,15 +846,31 @@ class _ChatScreenState extends State { return; } - final formattedPath = pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); + final connector = context.read(); + final allContacts = connector.allContacts; + + final formattedPath = PathHelper.formatPathHex(pathBytes); + final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.chat_fullPath), - content: SelectableText(formattedPath), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(formattedPath), + const SizedBox(height: 8), + SelectableText( + resolvedNames, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), actions: [ TextButton( onPressed: () => Navigator.push( @@ -1127,6 +1148,15 @@ class _ChatScreenState extends State { _showEmojiPicker(message, contact); }, ), + if (PlatformInfo.isDesktop) + ListTile( + leading: const Icon(Icons.route), + title: Text(context.l10n.chat_path), + onTap: () { + Navigator.pop(sheetContext); + _openMessagePath(message, contact); + }, + ), ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.common_copy), @@ -1237,6 +1267,7 @@ class _MessageBubble extends StatelessWidget { final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; + final void Function(Message message, String emoji)? onRetryReaction; final double textScale; const _MessageBubble({ @@ -1246,6 +1277,7 @@ class _MessageBubble extends StatelessWidget { required this.textScale, this.onTap, this.onLongPress, + this.onRetryReaction, }); @override @@ -1279,8 +1311,11 @@ class _MessageBubble extends StatelessWidget { : CrossAxisAlignment.start, children: [ GestureDetector( - onTap: onTap, + onTap: PlatformInfo.isDesktop ? null : onTap, onLongPress: onLongPress, + onSecondaryTapUp: PlatformInfo.isDesktop + ? (_) => onLongPress?.call() + : null, child: Row( mainAxisAlignment: isOutgoing ? MainAxisAlignment.end @@ -1397,7 +1432,8 @@ class _MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Linkify( + child: LinkHandler.buildLinkifyText( + context: context, text: messageText, style: TextStyle( color: textColor, @@ -1408,15 +1444,6 @@ class _MessageBubble extends StatelessWidget { decoration: TextDecoration.underline, fontSize: bodyFontSize * textScale, ), - options: const LinkifyOptions( - humanize: false, - defaultToHttps: false, - ), - linkifiers: const [UrlLinkifier()], - onOpen: (link) => LinkHandler.handleLinkTap( - context, - link.url, - ), ), ), if (!enableTracing && isOutgoing) ...[ @@ -1606,33 +1633,64 @@ class _MessageBubble extends StatelessWidget { children: message.reactions.entries.map((entry) { final emoji = entry.key; final count = entry.value; + final status = message.reactionStatuses[emoji]; + final isPending = + status == MessageStatus.pending || status == MessageStatus.sent; + final isFailed = status == MessageStatus.failed; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(emoji, style: const TextStyle(fontSize: 16)), - if (count > 1) ...[ - const SizedBox(width: 4), - Text( - '$count', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSecondaryContainer, - ), + return GestureDetector( + onTap: isFailed && onRetryReaction != null + ? () => onRetryReaction!(message, emoji) + : null, + child: Opacity( + opacity: isPending ? 0.5 : 1.0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isFailed + ? colorScheme.errorContainer + : colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isFailed + ? colorScheme.error + : colorScheme.outline.withValues(alpha: 0.3), + width: 1, ), - ], - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSecondaryContainer, + ), + ), + ], + if (isPending) ...[ + const SizedBox(width: 2), + SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: colorScheme.onSecondaryContainer, + ), + ), + ], + if (isFailed) ...[ + const SizedBox(width: 2), + Icon(Icons.replay, size: 10, color: colorScheme.error), + ], + ], + ), + ), ), ); }).toList(), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 23844fb..011e6d0 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -5,6 +5,7 @@ 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/utils/platform_info.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; @@ -1439,66 +1440,77 @@ class _ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - leading: CircleAvatar( - backgroundColor: _getTypeColor(contact.type), - child: _buildContactAvatar(contact), - ), - title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(contact.pathLabel, maxLines: 1, overflow: TextOverflow.ellipsis), - Text( - contact.shortPubKeyHex, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ], - ), - // Clamp text scaling in trailing section to prevent overflow while - // maintaining accessibility. Primary content (title/subtitle) scales normally. - trailing: MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear( - MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), - ), + return GestureDetector( + onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, + child: ListTile( + leading: CircleAvatar( + backgroundColor: _getTypeColor(contact.type), + child: _buildContactAvatar(contact), ), - child: SizedBox( - width: 120, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(height: 4), - ], - Text( - _formatLastSeen(context, lastSeen), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isFavorite) - Icon(Icons.star, size: 14, color: Colors.amber[700]), - if (isFavorite && contact.hasLocation) - const SizedBox(width: 2), - if (contact.hasLocation) - Icon(Icons.location_on, size: 14, color: Colors.grey[400]), + title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contact.pathLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + contact.shortPubKeyHex, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ], + ), + // Clamp text scaling in trailing section to prevent overflow while + // maintaining accessibility. Primary content (title/subtitle) scales normally. + trailing: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), + ), + ), + child: SizedBox( + width: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (unreadCount > 0) ...[ + UnreadBadge(count: unreadCount), + const SizedBox(height: 4), ], - ), - ], + Text( + _formatLastSeen(context, lastSeen), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isFavorite) + Icon(Icons.star, size: 14, color: Colors.amber[700]), + if (isFavorite && contact.hasLocation) + const SizedBox(width: 2), + if (contact.hasLocation) + Icon( + Icons.location_on, + size: 14, + color: Colors.grey[400], + ), + ], + ), + ], + ), ), ), + onTap: onTap, + onLongPress: onLongPress, ), - onTap: onTap, - onLongPress: onLongPress, ); } diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 7f065aa..4e7c6e8 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -9,6 +9,7 @@ import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../utils/contact_search.dart'; +import '../utils/platform_info.dart'; import '../widgets/app_bar.dart'; import '../widgets/list_filter_widget.dart'; @@ -88,7 +89,7 @@ class _DiscoveryScreenState extends State { itemCount: filteredAndSorted.length, itemBuilder: (context, index) { final contact = filteredAndSorted[index]; - return ListTile( + final tile = ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), child: Icon( @@ -120,6 +121,14 @@ class _DiscoveryScreenState extends State { onLongPress: () => _showContactContextMenu(contact, connector), ); + if (PlatformInfo.isDesktop) { + return GestureDetector( + onSecondaryTapUp: (_) => + _showContactContextMenu(contact, connector), + child: tile, + ); + } + return tile; }, ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index df16a59..6aaebf0 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -617,19 +617,6 @@ class _MapScreenState extends State { if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!)); } - // Fallback: for any last-hop byte with no GPS repeater, average the - // positions of contacts with known GPS that share the same last hop. - // Those contacts are all adjacent to the same unknown repeater, so their - // centroid is a reasonable proxy for its location. - for (final byte in lastHopBytes) { - if (repeaterByHash.containsKey(byte)) continue; - for (final c in withLocation) { - if (c.path.isNotEmpty && c.path.last == byte) { - anchorSet.add(LatLng(c.latitude!, c.longitude!)); - } - } - } - // Filter anchors that are geometrically inconsistent with radio range. // Two anchors more than 2 * maxRange apart cannot both be in direct radio // range of the same node, so isolated outliers are removed. @@ -641,15 +628,12 @@ class _MapScreenState extends State { final LatLng position; if (anchors.length == 1) { - // Offset single-anchor guesses so they don't overlap the repeater marker. - // Use the contact's public key byte as a deterministic angle seed. - const offsetDeg = 0.003; // ~330 m at the equator - final angle = (contact.publicKey[1] / 255.0) * 2 * pi; - position = LatLng( - anchors[0].latitude + offsetDeg * cos(angle), - anchors[0].longitude + offsetDeg * sin(angle), + // Spread single-anchor guesses around the anchor so they remain visible. + position = _offsetGuessedPosition( + anchors[0], + contact, + radiusMeters: 330, ); - if (!_checkLocationPlausibility( position.latitude, position.longitude, @@ -662,7 +646,11 @@ class _MapScreenState extends State { lat += a.latitude; lon += a.longitude; } - position = LatLng(lat / anchors.length, lon / anchors.length); + position = _offsetGuessedPosition( + LatLng(lat / anchors.length, lon / anchors.length), + contact, + radiusMeters: anchors.length >= 3 ? 80 : 120, + ); if (!_checkLocationPlausibility( position.latitude, position.longitude, @@ -682,6 +670,31 @@ class _MapScreenState extends State { return result; } + LatLng _offsetGuessedPosition( + LatLng anchor, + Contact contact, { + required double radiusMeters, + }) { + final seed = _guessSeed(contact.publicKey); + final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi; + final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle); + final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2); + final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle); + return LatLng( + anchor.latitude + latOffsetDeg, + anchor.longitude + lonOffsetDeg, + ); + } + + int _guessSeed(Uint8List publicKey) { + var seed = 0x811C9DC5; + for (final byte in publicKey) { + seed ^= byte; + seed = (seed * 0x01000193) & 0x7FFFFFFF; + } + return seed; + } + /// Estimates the free-space maximum LoRa range in km from the connected /// device's current radio parameters. Returns null if parameters are unknown. double? _estimateLoRaRangeKm(MeshCoreConnector connector) { diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 88c1f81..e6697f4 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -120,6 +120,30 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value)); } + Future setMaxRouteWeight(double value) async { + await updateSettings(_settings.copyWith(maxRouteWeight: value)); + } + + Future setInitialRouteWeight(double value) async { + await updateSettings(_settings.copyWith(initialRouteWeight: value)); + } + + Future setRouteWeightSuccessIncrement(double value) async { + await updateSettings( + _settings.copyWith(routeWeightSuccessIncrement: value), + ); + } + + Future setRouteWeightFailureDecrement(double value) async { + await updateSettings( + _settings.copyWith(routeWeightFailureDecrement: value), + ); + } + + Future setMaxMessageRetries(int value) async { + await updateSettings(_settings.copyWith(maxMessageRetries: value)); + } + Future setThemeMode(String value) async { await updateSettings(_settings.copyWith(themeMode: value)); } diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index b66ba51..2f10511 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -21,86 +21,74 @@ class _AckHistoryEntry { }); } -class _AckHashMapping { - final String messageId; - final DateTime timestamp; +/// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup. +typedef AckHashMapping = ({String messageId, DateTime timestamp, int attemptIndex}); - _AckHashMapping({required this.messageId, required this.timestamp}); +class RetryServiceConfig { + final void Function(Contact, String, int, int) sendMessage; + final void Function(String, Message) addMessage; + final void Function(Message) updateMessage; + final Function(Contact)? clearContactPath; + final Function(Contact, Uint8List, int)? setContactPath; + final int Function(int pathLength, int messageBytes, {String? contactKey})? + calculateTimeout; + final Uint8List? Function()? getSelfPublicKey; + final String Function(Contact, String)? prepareContactOutboundText; + final AppSettingsService? appSettingsService; + final AppDebugLogService? debugLogService; + final void Function(String, PathSelection, bool, int?)? recordPathResult; + final void Function(String, int, int, int)? onDeliveryObserved; + final PathSelection? Function( + String contactKey, + int attemptIndex, + int maxRetries, + List recentSelections, + )? selectRetryPath; + + const RetryServiceConfig({ + required this.sendMessage, + required this.addMessage, + required this.updateMessage, + this.clearContactPath, + this.setContactPath, + this.calculateTimeout, + this.getSelfPublicKey, + this.prepareContactOutboundText, + this.appSettingsService, + this.debugLogService, + this.recordPathResult, + this.onDeliveryObserved, + this.selectRetryPath, + }); } class MessageRetryService extends ChangeNotifier { - static const int maxRetries = 5; static const int maxAckHistorySize = 100; + int _maxRetries = 5; + int get maxRetries => _maxRetries; final Map _timeoutTimers = {}; final Map _pendingMessages = {}; final Map _pendingContacts = {}; - final Map _pendingPathSelections = {}; - final Map _ackHashToMessageId = - {}; // ackHashHex → messageId + timestamp for O(1) lookup - final Map> _expectedAckHashes = - {}; // Track all expected ACKs for retries (for history) - final List<_AckHistoryEntry> _ackHistory = - []; // 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) + final Map> _attemptPathHistory = {}; + final Map _ackHashToMessageId = {}; + final Map> _expectedAckHashes = {}; + final List<_AckHistoryEntry> _ackHistory = []; + final Map> _sendQueue = {}; + final Set _activeMessages = {}; + final Set _resolvedMessages = {}; + final Map _expectedHashToMessageId = {}; - Function(Contact, String, int, int)? _sendMessageCallback; - Function(String, Message)? _addMessageCallback; - Function(Message)? _updateMessageCallback; - Function(Contact)? _clearContactPathCallback; - Function(Contact, Uint8List, int)? _setContactPathCallback; - 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; + RetryServiceConfig? _config; MessageRetryService(); - void initialize({ - required Function(Contact, String, int, int) sendMessageCallback, - required Function(String, Message) addMessageCallback, - required Function(Message) updateMessageCallback, - Function(Contact)? clearContactPathCallback, - Function(Contact, Uint8List, int)? setContactPathCallback, - 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; - _updateMessageCallback = updateMessageCallback; - _clearContactPathCallback = clearContactPathCallback; - _setContactPathCallback = setContactPathCallback; - _calculateTimeoutCallback = calculateTimeoutCallback; - _getSelfPublicKeyCallback = getSelfPublicKeyCallback; - _prepareContactOutboundTextCallback = prepareContactOutboundTextCallback; - _appSettingsService = appSettingsService; - _debugLogService = debugLogService; - _recordPathResultCallback = recordPathResultCallback; - _onDeliveryObservedCallback = onDeliveryObservedCallback; + void initialize(RetryServiceConfig config) { + _config = config; + } + + void setMaxRetries(int value) { + _maxRetries = value.clamp(2, 10); } /// Compute expected ACK hash using same algorithm as firmware: @@ -139,17 +127,14 @@ class MessageRetryService extends ChangeNotifier { Future sendMessageWithRetry({ required Contact contact, required String text, - PathSelection? pathSelection, Uint8List? pathBytes, int? pathLength, }) async { final messageId = const Uuid().v4(); - final useFlood = pathSelection?.useFlood ?? false; - final messagePathBytes = - pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection); + final resolved = resolvePathSelection(contact); + final messagePathBytes = pathBytes ?? Uint8List.fromList(resolved.pathBytes); final messagePathLength = - pathLength ?? - _resolveMessagePathLength(contact, useFlood, pathSelection); + pathLength ?? (resolved.useFlood ? -1 : resolved.hopCount); final message = Message( senderKey: contact.publicKey, text: text, @@ -164,13 +149,8 @@ class MessageRetryService extends ChangeNotifier { _pendingMessages[messageId] = message; _pendingContacts[messageId] = contact; - if (pathSelection != null) { - _pendingPathSelections[messageId] = pathSelection; - } - if (_addMessageCallback != null) { - _addMessageCallback!(contact.publicKeyHex, message); - } + _config?.addMessage(contact.publicKeyHex, message); // Queue per contact — only one message in-flight at a time to avoid // overflowing the firmware's 8-entry expected_ack_table. @@ -200,13 +180,12 @@ class MessageRetryService extends ChangeNotifier { if (msg != null) { final failed = msg.copyWith(status: MessageStatus.failed); _pendingMessages[messageId] = failed; - _updateMessageCallback?.call(failed); + _config?.updateMessage(failed); } _onMessageResolved(messageId, contactKey); }); return; } - // Message was cancelled/cleaned up while queued — try next } } @@ -217,33 +196,87 @@ class MessageRetryService extends ChangeNotifier { _sendNextForContact(contactKey); } + PathSelection? _selectPathForAttempt(Message message, Contact contact) { + final config = _config; + if (config == null) return null; + final autoRotationEnabled = + config.appSettingsService?.settings.autoRouteRotationEnabled == true; + if (!autoRotationEnabled || + contact.pathOverride != null || + config.selectRetryPath == null) { + return null; + } + + final recentSelections = List.from( + _attemptPathHistory[message.messageId] ?? const [], + ); + return config.selectRetryPath!( + contact.publicKeyHex, + message.retryCount, + maxRetries, + recentSelections, + ); + } + + void _recordAttemptPathHistory(String messageId, PathSelection selection) { + if (selection.useFlood) return; + final history = _attemptPathHistory.putIfAbsent(messageId, () => []); + history.add(selection); + if (history.length > recentAttemptDiversityWindow) { + history.removeAt(0); + } + } + Future _attemptSend(String messageId) async { final message = _pendingMessages[messageId]; final contact = _pendingContacts[messageId]; + final config = _config; - if (message == null || contact == null) return; + if (message == null || contact == null || config == null) return; + + final currentSelection = _selectPathForAttempt(message, contact); + + if (currentSelection != null) { + final updatedMessage = message.copyWith( + pathLength: currentSelection.useFlood ? -1 : currentSelection.hopCount, + pathBytes: currentSelection.useFlood + ? Uint8List(0) + : Uint8List.fromList(currentSelection.pathBytes), + ); + _pendingMessages[messageId] = updatedMessage; + } else if (message.retryCount > 0) { + // No schedule entry for this retry — re-resolve path from current contact + // state so user's path override changes are picked up between retries. + final resolved = resolvePathSelection(contact); + final updatedMessage = message.copyWith( + pathLength: resolved.useFlood ? -1 : resolved.hopCount, + pathBytes: Uint8List.fromList(resolved.pathBytes), + ); + _pendingMessages[messageId] = updatedMessage; + } + + // Re-read after potential schedule update + final effectiveMessage = _pendingMessages[messageId] ?? message; // Sync path settings with device before sending - // Use the path that was captured when the message was first sent - if (_setContactPathCallback != null && _clearContactPathCallback != null) { - if (message.pathLength != null && message.pathLength! < 0) { - debugPrint( - 'Setting flood mode for retry attempt ${message.retryCount}', - ); - await _clearContactPathCallback!(contact); - } else if (message.pathLength != null && message.pathLength! >= 0) { - final pathStr = message.pathBytes.isEmpty - ? 'direct' - : message.pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(','); - debugPrint( - 'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}', - ); - await _setContactPathCallback!( + if (config.setContactPath != null && config.clearContactPath != null) { + final bool useFlood = currentSelection != null + ? currentSelection.useFlood + : (effectiveMessage.pathLength != null && effectiveMessage.pathLength! < 0); + final List pathBytes = currentSelection != null + ? currentSelection.pathBytes + : effectiveMessage.pathBytes; + final int hopCount = currentSelection != null + ? currentSelection.hopCount + : (effectiveMessage.pathLength ?? 0); + + if (useFlood) { + await config.clearContactPath!(contact); + } else if (effectiveMessage.pathLength != null) { + await config.setContactPath!( contact, - message.pathBytes, - message.pathLength!, + Uint8List.fromList(pathBytes), + hopCount, ); } } @@ -257,8 +290,6 @@ class MessageRetryService extends ChangeNotifier { ); 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', @@ -266,15 +297,19 @@ class MessageRetryService extends ChangeNotifier { return; } - final attempt = message.retryCount.clamp(0, 3); + if (currentSelection != null) { + _recordAttemptPathHistory(messageId, currentSelection); + } + + final attempt = message.retryCount; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; // Compute expected ACK hash that device will return in RESP_CODE_SENT // IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash - final selfPubKey = _getSelfPublicKeyCallback?.call(); + final selfPubKey = config.getSelfPublicKey?.call(); if (selfPubKey != null) { final outboundText = - _prepareContactOutboundTextCallback?.call(contact, message.text) ?? + config.prepareContactOutboundText?.call(contact, message.text) ?? message.text; final expectedHash = MessageRetryService.computeExpectedAckHash( timestampSeconds, @@ -290,43 +325,24 @@ class MessageRetryService extends ChangeNotifier { final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; - _debugLogService?.info( + config.debugLogService?.info( 'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)', tag: 'AckHash', ); - debugPrint( - 'Computed expected ACK hash $expectedHashHex for message $messageId', - ); } - // DEPRECATED: Old queue-based matching (kept for fallback) - _pendingMessageQueuePerContact[contact.publicKeyHex] ??= []; - _pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId); - - 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); - } + config.sendMessage(contact, message.text, attempt, timestampSeconds); } - bool updateMessageFromSent( - Uint8List ackHash, - int timeoutMs, { - bool allowQueueFallback = true, - }) { + bool updateMessageFromSent(Uint8List ackHash, int timeoutMs) { + final config = _config; + if (config == null) return false; + final ackHashHex = ackHash .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - // NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches) + // Try hash-based matching (fixes LoRa message drops causing mismatches) String? messageId = _expectedHashToMessageId.remove(ackHashHex); Contact? contact; @@ -338,89 +354,31 @@ class MessageRetryService extends ChangeNotifier { final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; - _debugLogService?.info( + config.debugLogService?.info( 'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}', tag: 'AckHash', ); - debugPrint( - 'Hash-based match: ACK hash $ackHashHex → message $messageId ✓', - ); - - // Remove from old queue since we matched - _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); - if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? - false) { - _pendingMessageQueuePerContact.remove(contact.publicKeyHex); - } } else { - _debugLogService?.warn( + config.debugLogService?.warn( 'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending', tag: 'AckHash', ); - debugPrint('Hash matched $messageId but message no longer pending'); messageId = null; contact = null; } } - // 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', - tag: 'AckHash', - ); - debugPrint( - 'Hash-based match failed for $ackHashHex, falling back to queue-based matching', - ); - - // 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; - - // 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]; - debugPrint( - 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', - ); - break; - } - debugPrint('Dequeued stale message $candidateMessageId - skipping'); - } - if (messageId != null) break; - } - } - if (messageId == null || contact == null) { debugPrint('No pending message found for ACK hash: $ackHashHex'); return false; } - // Store the mapping for future lookups (e.g., when ACK arrives) - // Keep timestamp so we can clean up old mappings later - _ackHashToMessageId[ackHashHex] = _AckHashMapping( + final message = _pendingMessages[messageId]!; + _ackHashToMessageId[ackHashHex] = ( messageId: messageId, timestamp: DateTime.now(), + attemptIndex: message.retryCount, ); - debugPrint('Mapped ACK hash $ackHashHex to message $messageId'); - - final message = _pendingMessages[messageId]; - final selection = _pendingPathSelections[messageId]; - - if (message == null) { - debugPrint( - 'Message $messageId no longer pending for ACK hash: $ackHashHex', - ); - _ackHashToMessageId.remove(ackHashHex); - return false; - } // Add this ACK hash to the list of expected ACKs for this message (for history) _expectedAckHashes[messageId] ??= []; @@ -428,37 +386,20 @@ class MessageRetryService extends ChangeNotifier { (hash) => listEquals(hash, ackHash), )) { _expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash)); - debugPrint( - 'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})', - ); } // 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; - } + final pathLengthValue = message.pathLength ?? contact.pathLength; int actualTimeout = timeoutMs; - if (_calculateTimeoutCallback != null) { - final calculated = _calculateTimeoutCallback!( + if (config.calculateTimeout != null) { + final calculated = config.calculateTimeout!( pathLengthValue, message.text.length, contactKey: contact.publicKeyHex, ); - // 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', - ); } } @@ -470,18 +411,26 @@ class MessageRetryService extends ChangeNotifier { ); _pendingMessages[messageId] = updatedMessage; - - if (_updateMessageCallback != null) { - _updateMessageCallback!(updatedMessage); - } + config.updateMessage(updatedMessage); _startTimeoutTimer(messageId, actualTimeout); - debugPrint('Updated message $messageId with ACK hash: $ackHashHex'); return true; } bool get hasPendingMessages => _pendingMessages.isNotEmpty; + /// Update the stored contact snapshot for all pending messages to this contact. + /// Call this when the contact's pathOverride changes so retries use the new path. + void updatePendingContact(Contact contact) { + final keys = _pendingContacts.entries + .where((e) => e.value.publicKeyHex == contact.publicKeyHex) + .map((e) => e.key) + .toList(); + for (final key in keys) { + _pendingContacts[key] = contact; + } + } + void _startTimeoutTimer(String messageId, int timeoutMs) { _timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () { @@ -489,10 +438,24 @@ class MessageRetryService extends ChangeNotifier { }); } + void _cleanupMessage(String messageId) { + _moveAckHashesToHistory(messageId); + _ackHashToMessageId.removeWhere( + (_, mapping) => mapping.messageId == messageId, + ); + _expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId); + _pendingMessages.remove(messageId); + _pendingContacts.remove(messageId); + _attemptPathHistory.remove(messageId); + _timeoutTimers.remove(messageId); + _resolvedMessages.remove(messageId); + } + void _handleTimeout(String messageId) { final message = _pendingMessages[messageId]; final contact = _pendingContacts[messageId]; - final selection = _pendingPathSelections[messageId]; + final config = _config; + final selection = message != null ? _selectionFromMessage(message) : null; if (message == null || contact == null) { debugPrint( @@ -504,44 +467,40 @@ class MessageRetryService extends ChangeNotifier { final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; - _debugLogService?.warn( + config?.debugLogService?.warn( 'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying', tag: 'AckHash', ); - debugPrint( - 'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})', - ); if (message.retryCount < maxRetries - 1) { final backoffMs = 1000 * (1 << message.retryCount); + if (selection != null) { + _recordPathResultFromMessage( + contact.publicKeyHex, + message, + selection, + false, + null, + ); + } + final updatedMessage = message.copyWith( retryCount: message.retryCount + 1, status: MessageStatus.pending, - // Keep expectedAckHash - it will be updated when the new attempt is sent ); _pendingMessages[messageId] = updatedMessage; + config?.updateMessage(updatedMessage); - if (_updateMessageCallback != null) { - _updateMessageCallback!(updatedMessage); - } - - _debugLogService?.info( + config?.debugLogService?.info( 'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff', tag: 'AckHash', ); - debugPrint('Scheduling retry after ${backoffMs}ms'); - // Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives _timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () { - // Double-check message is still pending before retry if (_pendingMessages.containsKey(messageId)) { _attemptSend(messageId); - } else { - debugPrint( - 'Retry cancelled: message $messageId was delivered while waiting', - ); } }); } else { @@ -549,10 +508,9 @@ class MessageRetryService extends ChangeNotifier { final failedMessage = message.copyWith(status: MessageStatus.failed); _pendingMessages[messageId] = failedMessage; - // Check if we should clear the path on max retry - if (_appSettingsService?.settings.clearPathOnMaxRetry == true && - _clearContactPathCallback != null) { - _clearContactPathCallback!(contact); + if (config?.appSettingsService?.settings.clearPathOnMaxRetry == true && + config?.clearContactPath != null) { + config!.clearContactPath!(contact); } _recordPathResultFromMessage( @@ -563,34 +521,16 @@ class MessageRetryService extends ChangeNotifier { null, ); - if (_updateMessageCallback != null) { - _updateMessageCallback!(failedMessage); - } + config?.updateMessage(failedMessage); 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); - } + _cleanupMessage(messageId); }); } } @@ -606,14 +546,9 @@ class MessageRetryService extends ChangeNotifier { ), ); - // Trim history to max size (rolling buffer) while (_ackHistory.length > maxAckHistorySize) { _ackHistory.removeAt(0); } - - debugPrint( - 'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})', - ); } } @@ -621,9 +556,6 @@ class MessageRetryService extends ChangeNotifier { for (final entry in _ackHistory) { for (final expectedHash in entry.ackHashes) { if (listEquals(expectedHash, ackHash)) { - debugPrint( - 'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s', - ); return true; } } @@ -632,14 +564,14 @@ class MessageRetryService extends ChangeNotifier { } void handleAckReceived(Uint8List ackHash, int tripTimeMs) { + final config = _config; String? matchedMessageId; + int? matchedAttemptIndex; final ackHashHex = ackHash .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); - debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms'); - - // First, clean up old ACK hash mappings (older than 15 minutes) + // Clean up old ACK hash mappings (older than 15 minutes) final cutoffTime = DateTime.now().subtract(const Duration(minutes: 15)); final hashesToRemove = []; for (var entry in _ackHashToMessageId.entries) { @@ -650,24 +582,18 @@ class MessageRetryService extends ChangeNotifier { for (var hash in hashesToRemove) { _ackHashToMessageId.remove(hash); } - if (hashesToRemove.isNotEmpty) { - debugPrint('Cleaned up ${hashesToRemove.length} old ACK hash mappings'); - } // Use direct O(1) lookup via ACK hash mapping final mapping = _ackHashToMessageId[ackHashHex]; if (mapping != null) { matchedMessageId = mapping.messageId; - debugPrint('Matched ACK to message via direct lookup: $matchedMessageId'); + matchedAttemptIndex = mapping.attemptIndex; } else { - _debugLogService?.warn( + config?.debugLogService?.warn( 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback', tag: 'AckHash', ); // Fallback: Check against ALL expected ACK hashes (from all retry attempts) - debugPrint( - 'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)', - ); for (var entry in _expectedAckHashes.entries) { final messageId = entry.key; final expectedHashes = entry.value; @@ -675,9 +601,7 @@ class MessageRetryService extends ChangeNotifier { for (final expectedHash in expectedHashes) { if (listEquals(expectedHash, ackHash)) { matchedMessageId = messageId; - debugPrint( - 'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})', - ); + matchedAttemptIndex = expectedHashes.indexOf(expectedHash); break; } } @@ -689,27 +613,22 @@ class MessageRetryService extends ChangeNotifier { if (matchedMessageId != null) { 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]; + final ackedAttempt = matchedAttemptIndex ?? message.retryCount; + final selection = _selectionFromMessage(message); final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; - _debugLogService?.info( - 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms', + config?.debugLogService?.info( + 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} on retry ${ackedAttempt + 1} in ${tripTimeMs}ms', tag: 'AckHash', ); - // Cancel any pending timeout or retry _timeoutTimers[matchedMessageId]?.cancel(); - _timeoutTimers.remove(matchedMessageId); final deliveredMessage = message.copyWith( status: MessageStatus.delivered, @@ -717,36 +636,9 @@ 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, - ); + _cleanupMessage(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) { - _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove( - matchedMessageId, - ); - if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? - false) { - _pendingMessageQueuePerContact.remove(contact.publicKeyHex); - } - } - - if (_updateMessageCallback != null) { - _updateMessageCallback!(deliveredMessage); - } + config?.updateMessage(deliveredMessage); if (contact != null) { _recordPathResultFromMessage( @@ -756,10 +648,10 @@ class MessageRetryService extends ChangeNotifier { true, tripTimeMs, ); - if (_onDeliveryObservedCallback != null && + if (config?.onDeliveryObserved != null && tripTimeMs > 0 && message.pathLength != null) { - _onDeliveryObservedCallback!( + config!.onDeliveryObserved!( contact.publicKeyHex, message.pathLength!, message.text.length, @@ -771,15 +663,13 @@ class MessageRetryService extends ChangeNotifier { notifyListeners(); } else { - // Check ACK history for recently completed messages if (_checkAckHistory(ackHash)) { - _debugLogService?.info( + config?.debugLogService?.info( 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)', tag: 'AckHash', ); - debugPrint('ACK matched a recently completed message from history'); } else { - _debugLogService?.error( + config?.debugLogService?.error( 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!', tag: 'AckHash', ); @@ -788,57 +678,6 @@ class MessageRetryService extends ChangeNotifier { } } - Uint8List _resolveMessagePathBytes( - Contact contact, - bool forceFlood, - PathSelection? selection, - ) { - // Priority 1: Check user's path override - if (contact.pathOverride != null) { - if (contact.pathOverride! < 0) { - return Uint8List(0); // Force flood - } - return contact.pathOverrideBytes ?? Uint8List(0); - } - - // Priority 2: Check forceFlood or device flood mode - if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { - return Uint8List(0); - } - - // Priority 3: Check PathSelection (auto-rotation) - if (selection != null && selection.pathBytes.isNotEmpty) { - return Uint8List.fromList(selection.pathBytes); - } - - // Priority 4: Use device's discovered path - return contact.path; - } - - int? _resolveMessagePathLength( - Contact contact, - bool forceFlood, - PathSelection? selection, - ) { - // Priority 1: Check user's path override - if (contact.pathOverride != null) { - return contact.pathOverride; - } - - // Priority 2: Check forceFlood or device flood mode - if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { - return -1; - } - - // Priority 3: Check PathSelection (auto-rotation) - if (selection != null && selection.pathBytes.isNotEmpty) { - return selection.hopCount; - } - - // Priority 4: Use device's discovered path - return contact.pathLength; - } - String? getContactKeyForAckHash(Uint8List ackHash) { for (var entry in _pendingMessages.entries) { final message = entry.value; @@ -866,15 +705,11 @@ class MessageRetryService extends ChangeNotifier { bool success, int? tripTimeMs, ) { - if (_recordPathResultCallback == null) return; + final callback = _config?.recordPathResult; + if (callback == null) return; final recordSelection = selection ?? _selectionFromMessage(message); if (recordSelection == null) return; - _recordPathResultCallback!( - contactKey, - recordSelection, - success, - tripTimeMs, - ); + callback(contactKey, recordSelection, success, tripTimeMs); } PathSelection? _selectionFromMessage(Message message) { @@ -899,11 +734,10 @@ class MessageRetryService extends ChangeNotifier { _timeoutTimers.clear(); _pendingMessages.clear(); _pendingContacts.clear(); - _pendingPathSelections.clear(); + _attemptPathHistory.clear(); _expectedAckHashes.clear(); _ackHistory.clear(); _ackHashToMessageId.clear(); - _pendingMessageQueuePerContact.clear(); _sendQueue.clear(); _activeMessages.clear(); _resolvedMessages.clear(); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 62d3796..b367e0e 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/foundation.dart'; +import '../helpers/reaction_helper.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_info.dart'; @@ -145,6 +146,19 @@ class NotificationService { return true; } + /// Format special message types for human-readable notifications. + static String formatNotificationText(String text) { + final trimmed = text.trim(); + final reaction = ReactionHelper.parseReaction(trimmed); + if (reaction != null) { + return 'Reacted ${reaction.emoji}'; + } + if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) { + return 'Sent a GIF'; + } + return text; + } + Future _showMessageNotificationImpl({ required String contactName, required String message, @@ -187,7 +201,7 @@ class NotificationService { await _notifications.show( id: contactId?.hashCode ?? 0, title: contactName, - body: message, + body: formatNotificationText(message), notificationDetails: notificationDetails, payload: 'message:$contactId', ); @@ -283,7 +297,7 @@ class NotificationService { macOS: macDetails, ); - final preview = message.trim(); + final preview = formatNotificationText(message.trim()); final body = preview.isEmpty ? _l10n.notification_receivedNewMessage : preview; @@ -430,6 +444,7 @@ class NotificationService { Future showChannelMessageNotification({ required String channelName, + required String senderName, required String message, int? channelIndex, int? badgeCount, @@ -440,7 +455,7 @@ class NotificationService { _PendingNotification( type: _NotificationType.channelMessage, title: channelName, - body: message, + body: '$senderName: $message', id: channelIndex?.toString(), badgeCount: badgeCount, ), diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 569fada..809f867 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -9,6 +9,8 @@ class PathHistoryService extends ChangeNotifier { final Map _cache = {}; final Map _autoRotationIndex = {}; final Map _floodStats = {}; + final Set _pendingLoads = {}; + final Map> _deferredRecords = {}; // LRU cache eviction tracking static const int _maxCachedContacts = 50; @@ -18,7 +20,6 @@ class PathHistoryService extends ChangeNotifier { int _version = 0; int get version => _version; - static const int _autoRotationTopCount = 3; PathHistoryService(this._storage); @@ -26,17 +27,21 @@ class PathHistoryService extends ChangeNotifier { // Load cached path histories on startup if needed } - void handlePathUpdated(Contact contact) { - if (contact.pathLength < 0) return; - + void handlePathUpdated(Contact contact, {double initialWeight = 1.0}) { + if (contact.pathLength < 0 && contact.path.isEmpty) return; + final hopCount = contact.pathLength < 0 + ? contact.path.length + : contact.pathLength; _addPathRecord( contactPubKeyHex: contact.publicKeyHex, - hopCount: contact.pathLength, + hopCount: hopCount, tripTimeMs: 0, wasFloodDiscovery: true, pathBytes: contact.path, successCount: 0, failureCount: 0, + routeWeight: initialWeight, + timestamp: null, ); } @@ -54,6 +59,44 @@ class PathHistoryService extends ChangeNotifier { pathBytes: selection.pathBytes, successCount: 0, failureCount: 0, + timestamp: null, + ); + } + + /// When a flood message is delivered, credit the contact's current device + /// path so that the route the ACK traveled back through gets a weight boost. + void recordFloodPathAttribution({ + required String contactPubKeyHex, + required List pathBytes, + required int hopCount, + int? tripTimeMs, + double successIncrement = 0.5, + double maxWeight = 5.0, + }) { + if (pathBytes.isEmpty || hopCount < 0) return; + + final existing = _findPathRecord(contactPubKeyHex, pathBytes); + final successCount = (existing?.successCount ?? 0) + 1; + final failureCount = existing?.failureCount ?? 0; + + final currentWeight = existing?.routeWeight ?? 1.0; + final newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight); + + debugPrint( + 'Flood path attribution: crediting path [${pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',')}] ' + 'for $contactPubKeyHex (weight $currentWeight → $newWeight)', + ); + + _addPathRecord( + contactPubKeyHex: contactPubKeyHex, + hopCount: hopCount, + tripTimeMs: tripTimeMs ?? existing?.tripTimeMs ?? 0, + wasFloodDiscovery: true, + pathBytes: pathBytes, + successCount: successCount, + failureCount: failureCount, + routeWeight: newWeight, + timestamp: DateTime.now(), ); } @@ -62,6 +105,9 @@ class PathHistoryService extends ChangeNotifier { PathSelection selection, { required bool success, int? tripTimeMs, + double successIncrement = 0.5, + double failureDecrement = 0.5, + double maxWeight = 5.0, }) { if (selection.useFlood) { final stats = _floodStats.putIfAbsent( @@ -82,6 +128,18 @@ class PathHistoryService extends ChangeNotifier { final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0); final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1); + final currentWeight = existing?.routeWeight ?? 1.0; + double newWeight; + if (success) { + newWeight = (currentWeight + successIncrement).clamp(0.0, maxWeight); + } else { + newWeight = currentWeight - failureDecrement; + if (newWeight <= 0) { + removePathRecord(contactPubKeyHex, selection.pathBytes); + return; + } + } + _addPathRecord( contactPubKeyHex: contactPubKeyHex, hopCount: selection.hopCount, @@ -90,37 +148,68 @@ class PathHistoryService extends ChangeNotifier { pathBytes: selection.pathBytes, successCount: successCount, failureCount: failureCount, + routeWeight: newWeight, + timestamp: success ? DateTime.now() : existing?.timestamp, ); } - PathSelection getNextAutoPathSelection(String contactPubKeyHex) { - final ranked = _getRankedPaths( - contactPubKeyHex, - ).take(_autoRotationTopCount).toList(); + PathSelection selectPathForAttempt( + String contactPubKeyHex, { + required int attemptIndex, + required int maxRetries, + List recentSelections = const [], + }) { + if (maxRetries <= 0 || attemptIndex >= maxRetries - 1) { + return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); + } + + final ranked = _getRankedPaths(contactPubKeyHex); if (ranked.isEmpty) { return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); } _trackAccess(contactPubKeyHex); - final selections = - ranked - .map( - (path) => PathSelection( - pathBytes: path.pathBytes, - hopCount: path.hopCount, - useFlood: false, - ), - ) - .toList() - ..add( - const PathSelection(pathBytes: [], hopCount: -1, useFlood: true), - ); + final recentPaths = recentSelections + .where((selection) => !selection.useFlood) + .map((selection) => selection.pathBytes) + .toList(); + final candidates = recentPaths.isEmpty + ? ranked + : ranked + .where( + (path) => !recentPaths.any( + (recentPath) => _pathsEqual(path.pathBytes, recentPath), + ), + ) + .toList(); + final selected = candidates.isNotEmpty + ? (recentPaths.isEmpty + ? _selectRotatedCandidate(contactPubKeyHex, candidates) + : candidates.first) + : ranked.first; + + return PathSelection( + pathBytes: selected.pathBytes, + hopCount: selected.hopCount, + useFlood: false, + ); + } + + PathRecord _selectRotatedCandidate( + String contactPubKeyHex, + List candidates, + ) { + if (candidates.length <= 1) { + _autoRotationIndex[contactPubKeyHex] = 0; + return candidates.first; + } final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0; - final selection = selections[currentIndex % selections.length]; - _autoRotationIndex[contactPubKeyHex] = currentIndex + 1; - return selection; + final selectedIndex = currentIndex % candidates.length; + _autoRotationIndex[contactPubKeyHex] = + (selectedIndex + 1) % candidates.length; + return candidates[selectedIndex]; } void _addPathRecord({ @@ -131,37 +220,68 @@ class PathHistoryService extends ChangeNotifier { required List pathBytes, required int successCount, required int failureCount, + double routeWeight = 1.0, + DateTime? timestamp, }) { var history = _cache[contactPubKeyHex]; if (history == null) { + // If a load is already in progress, defer this record + if (_pendingLoads.contains(contactPubKeyHex)) { + _deferredRecords.putIfAbsent(contactPubKeyHex, () => []); + _deferredRecords[contactPubKeyHex]!.add( + _DeferredPathRecord( + hopCount: hopCount, + tripTimeMs: tripTimeMs, + wasFloodDiscovery: wasFloodDiscovery, + pathBytes: pathBytes, + successCount: successCount, + failureCount: failureCount, + routeWeight: routeWeight, + timestamp: timestamp, + ), + ); + return; + } + + _pendingLoads.add(contactPubKeyHex); _loadHistoryFromStorage(contactPubKeyHex).then((loaded) { - if (loaded != null) { - _cache[contactPubKeyHex] = loaded; - _addPathRecordInternal( - contactPubKeyHex, - hopCount, - tripTimeMs, - wasFloodDiscovery, - pathBytes, - successCount, - failureCount, - ); - } else { - _cache[contactPubKeyHex] = ContactPathHistory( - contactPubKeyHex: contactPubKeyHex, - recentPaths: [], - ); - _addPathRecordInternal( - contactPubKeyHex, - hopCount, - tripTimeMs, - wasFloodDiscovery, - pathBytes, - successCount, - failureCount, - ); + _cache[contactPubKeyHex] = + loaded ?? + ContactPathHistory( + contactPubKeyHex: contactPubKeyHex, + recentPaths: [], + ); + _addPathRecordInternal( + contactPubKeyHex, + hopCount, + tripTimeMs, + wasFloodDiscovery, + pathBytes, + successCount, + failureCount, + routeWeight, + timestamp, + ); + + // Apply any deferred records + final deferred = _deferredRecords.remove(contactPubKeyHex); + if (deferred != null) { + for (final record in deferred) { + _addPathRecordInternal( + contactPubKeyHex, + record.hopCount, + record.tripTimeMs, + record.wasFloodDiscovery, + record.pathBytes, + record.successCount, + record.failureCount, + record.routeWeight, + record.timestamp, + ); + } } + _pendingLoads.remove(contactPubKeyHex); }); return; } @@ -174,6 +294,8 @@ class PathHistoryService extends ChangeNotifier { pathBytes, successCount, failureCount, + routeWeight, + timestamp, ); } @@ -185,6 +307,8 @@ class PathHistoryService extends ChangeNotifier { List pathBytes, int successCount, int failureCount, + double routeWeight, + DateTime? timestamp, ) { var history = _cache[contactPubKeyHex]; if (history == null) return; @@ -198,16 +322,18 @@ class PathHistoryService extends ChangeNotifier { tripTimeMs = existing.tripTimeMs; } wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery; + timestamp ??= existing.timestamp; } final newRecord = PathRecord( hopCount: hopCount, tripTimeMs: tripTimeMs, - timestamp: DateTime.now(), + timestamp: timestamp, wasFloodDiscovery: wasFloodDiscovery, pathBytes: pathBytes, successCount: successCount, failureCount: failureCount, + routeWeight: routeWeight, ); final updatedPaths = List.from(history.recentPaths); @@ -275,6 +401,23 @@ class PathHistoryService extends ChangeNotifier { return history?.mostRecent; } + ({ + int successCount, + int failureCount, + int lastTripTimeMs, + DateTime? lastUsed, + })? + getFloodStats(String contactPubKeyHex) { + final stats = _floodStats[contactPubKeyHex]; + if (stats == null) return null; + return ( + successCount: stats.successCount, + failureCount: stats.failureCount, + lastTripTimeMs: stats.lastTripTimeMs, + lastUsed: stats.lastUsed, + ); + } + Future clearPathHistory(String contactPubKeyHex) async { _cache.remove(contactPubKeyHex); _cacheAccessOrder.remove(contactPubKeyHex); @@ -322,26 +465,81 @@ class PathHistoryService extends ChangeNotifier { final ranked = List.from(history.recentPaths) ..removeWhere((p) => p.pathBytes.isEmpty); + final fastestTripMs = _getFastestKnownTripMs(ranked); + final highestRouteWeight = _getHighestKnownRouteWeight(ranked); ranked.sort((a, b) { - final aRate = - (a.successCount + 1) / (a.successCount + a.failureCount + 2); - final bRate = - (b.successCount + 1) / (b.successCount + b.failureCount + 2); - if (aRate != bRate) return bRate.compareTo(aRate); - if (a.successCount != b.successCount) { - return b.successCount.compareTo(a.successCount); + final scoreCompare = _scorePathRecord( + b, + fastestTripMs: fastestTripMs, + highestRouteWeight: highestRouteWeight, + ).compareTo( + _scorePathRecord( + a, + fastestTripMs: fastestTripMs, + highestRouteWeight: highestRouteWeight, + ), + ); + if (scoreCompare != 0) { + return scoreCompare; + } + if (a.routeWeight != b.routeWeight) { + return b.routeWeight.compareTo(a.routeWeight); } - final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs; final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs; if (aTrip != bTrip) return aTrip.compareTo(bTrip); - return b.timestamp.compareTo(a.timestamp); + final aTime = a.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0); + final bTime = b.timestamp ?? DateTime.fromMillisecondsSinceEpoch(0); + return bTime.compareTo(aTime); }); return ranked; } + int? _getFastestKnownTripMs(List paths) { + final knownTrips = paths + .where((path) => path.tripTimeMs > 0) + .map((path) => path.tripTimeMs) + .toList(); + if (knownTrips.isEmpty) return null; + return knownTrips.reduce((a, b) => a < b ? a : b); + } + + double _getHighestKnownRouteWeight(List paths) { + if (paths.isEmpty) return 1.0; + final highestWeight = paths + .map((path) => path.routeWeight) + .reduce((a, b) => a > b ? a : b); + return highestWeight <= 0 ? 1.0 : highestWeight; + } + + double _scorePathRecord( + PathRecord path, { + required int? fastestTripMs, + required double highestRouteWeight, + }) { + final totalAttempts = path.successCount + path.failureCount; + final reliability = (path.successCount + 1) / (totalAttempts + 2); + final latency = fastestTripMs == null || path.tripTimeMs <= 0 + ? 0.6 + : (fastestTripMs / path.tripTimeMs).clamp(0.0, 1.0); + final freshness = path.timestamp == null + ? 0.0 + : 1.0 / + (1.0 + + (DateTime.now().difference(path.timestamp!).inMinutes / + 60.0 / + 24.0)); + final routeWeight = + (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0); + + return (reliability * 0.45) + + (latency * 0.25) + + (freshness * 0.1) + + (routeWeight * 0.2); + } + bool _pathsEqual(List a, List b) { return listEquals(a, b); } @@ -369,6 +567,28 @@ class PathHistoryService extends ChangeNotifier { } } +class _DeferredPathRecord { + final int hopCount; + final int tripTimeMs; + final bool wasFloodDiscovery; + final List pathBytes; + final int successCount; + final int failureCount; + final double routeWeight; + final DateTime? timestamp; + + _DeferredPathRecord({ + required this.hopCount, + required this.tripTimeMs, + required this.wasFloodDiscovery, + required this.pathBytes, + required this.successCount, + required this.failureCount, + this.routeWeight = 1.0, + this.timestamp, + }); +} + class _FloodStats { int successCount = 0; int failureCount = 0; diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 7bf44bd..ddb42f6 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -108,6 +108,7 @@ class ChannelMessageStore { 'pathVariants': msg.pathVariants.map(base64Encode).toList(), 'repeats': msg.repeats.map(_repeatToJson).toList(), 'messageId': msg.messageId, + 'packetHash': msg.packetHash, 'replyToMessageId': msg.replyToMessageId, 'replyToSenderName': msg.replyToSenderName, 'replyToText': msg.replyToText, @@ -143,6 +144,7 @@ class ChannelMessageStore { const [], channelIndex: json['channelIndex'] as int?, messageId: json['messageId'] as String?, + packetHash: json['packetHash'] as String?, replyToMessageId: json['replyToMessageId'] as String?, replyToSenderName: json['replyToSenderName'] as String?, replyToText: json['replyToText'] as String?, diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 9a39e3f..44d3621 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -96,6 +96,9 @@ class MessageStore { ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'reactionStatuses': msg.reactionStatuses.map( + (key, value) => MapEntry(key, value.index), + ), 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), }; } @@ -135,6 +138,11 @@ class MessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionStatuses: + (json['reactionStatuses'] as Map?)?.map( + (key, value) => MapEntry(key, MessageStatus.values[value as int]), + ) ?? + {}, fourByteRoomContactKey: json['fourByteRoomContactKey'] != null ? Uint8List.fromList( base64Decode(json['fourByteRoomContactKey'] as String), diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 861241b..f667256 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../helpers/path_helper.dart'; import '../services/path_history_service.dart'; import 'path_selection_dialog.dart'; @@ -40,7 +41,8 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { ); } - String _formatRelativeTime(BuildContext context, DateTime time) { + String _formatRelativeTime(BuildContext context, DateTime? time) { + if (time == null) return '—'; final l10n = context.l10n; final diff = DateTime.now().difference(time); if (diff.inSeconds < 60) return l10n.time_justNow; @@ -61,15 +63,31 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { return; } - final formattedPath = pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); + final connector = context.read(); + final allContacts = connector.allContacts; + + final formattedPath = PathHelper.formatPathHex(pathBytes); + final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.chat_fullPath), - content: SelectableText(formattedPath), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(formattedPath), + const SizedBox(height: 8), + SelectableText( + resolvedNames, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), actions: [ TextButton( onPressed: () => Navigator.push( @@ -262,16 +280,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { radius: 16, backgroundColor: color, child: Text( - '${path.hopCount}', - style: const TextStyle(fontSize: 12), + path.routeWeight.toStringAsFixed(1), + style: const TextStyle(fontSize: 10), ), ), title: Text( l10n.chat_hopsCount(path.hopCount), style: const TextStyle(fontSize: 14), ), + isThreeLine: true, subtitle: Text( - '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}', + '${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}', style: const TextStyle(fontSize: 11), ), trailing: Row( @@ -346,6 +365,40 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { Text(l10n.chat_noPathHistoryYet), const Divider(), ], + // Flood delivery stats + Builder( + builder: (context) { + final floodStats = pathService.getFloodStats( + currentContact.publicKeyHex, + ); + if (floodStats == null || + (floodStats.successCount == 0 && + floodStats.failureCount == 0)) { + return const SizedBox.shrink(); + } + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: const CircleAvatar( + radius: 16, + backgroundColor: Colors.blue, + child: Icon(Icons.waves, size: 16), + ), + title: const Text( + 'Flood Mode', + style: TextStyle(fontSize: 14), + ), + subtitle: Text( + '${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures' + '${floodStats.lastTripTimeMs > 0 ? ' • ${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}' + '${floodStats.lastUsed != null ? ' • ${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}', + style: const TextStyle(fontSize: 11), + ), + ), + ); + }, + ), const SizedBox(height: 8), Text( l10n.chat_pathActions, diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart new file mode 100644 index 0000000..38abf2c --- /dev/null +++ b/test/helpers/path_helper_test.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; +import 'package:meshcore_open/models/contact.dart'; + +Contact _contact({ + required int firstByte, + required String name, + required int type, +}) { + final key = Uint8List(32)..[0] = firstByte; + return Contact( + publicKey: key, + name: name, + type: type, + pathLength: 0, + path: Uint8List(0), + lastSeen: DateTime.now(), + ); +} + +void main() { + test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () { + final contacts = [ + _contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat), + _contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater), + _contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom), + ]; + + final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts); + + expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); + }); +} diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart new file mode 100644 index 0000000..165b91d --- /dev/null +++ b/test/models/model_changes_test.dart @@ -0,0 +1,357 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/path_history.dart'; +import 'package:meshcore_open/models/app_settings.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +// Builds a valid contact frame with the given pathLen and optional overrides. +// Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)] +Uint8List _buildContactFrame({ + int pathLen = 0, + Uint8List? pubKey, + String name = 'TestNode', +}) { + final writer = BytesBuilder(); + writer.addByte(respCodeContact); // 3 + writer.add(pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1))); // valid pubkey + writer.addByte(1); // type + writer.addByte(0); // flags + writer.addByte(pathLen); + writer.add(Uint8List(64)); // path bytes (zeros) + // name (32 bytes, null-padded) + final nameBytes = Uint8List(32); + final encoded = name.codeUnits; + for (var i = 0; i < encoded.length && i < 31; i++) { + nameBytes[i] = encoded[i]; + } + writer.add(nameBytes); + // timestamp (4 bytes LE) - some nonzero value + writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); + // lat, lon (4 bytes each) + writer.add(Uint8List(4)); // lat + writer.add(Uint8List(4)); // lon + return Uint8List.fromList(writer.toBytes()); +} + +void main() { + group('Contact.fromFrame — pathLen mapping', () { + test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () { + final frame = _buildContactFrame(pathLen: 0); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(0)); + }); + + test('pathLen == 1 → pathLength == 1', () { + final frame = _buildContactFrame(pathLen: 1); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(1)); + }); + + test('pathLen == 64 (maxPathSize) → pathLength == 64', () { + final frame = _buildContactFrame(pathLen: maxPathSize); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(maxPathSize)); + }); + + test('pathLen == 0xFF → pathLength == -1 (flood)', () { + final frame = _buildContactFrame(pathLen: 0xFF); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(-1)); + }); + + test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { + final frame = _buildContactFrame(pathLen: 65); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.pathLength, equals(-1)); + }); + }); + + group('Contact.fromFrame — corrupt contact guards', () { + test('all-zero public key → returns null', () { + final zeroPubKey = Uint8List(32); // all zeros + final frame = _buildContactFrame(pubKey: zeroPubKey); + final contact = Contact.fromFrame(frame); + expect(contact, isNull); + }); + + test('mostly-zero public key (>16 zeros out of 32) → returns null', () { + // 17 zeros out of 32 bytes exceeds pubKeySize ~/ 2 == 16 + final pubKey = Uint8List(32); + pubKey[0] = 0xAB; + pubKey[1] = 0xCD; + pubKey[2] = 0xEF; + pubKey[3] = 0x12; + pubKey[4] = 0x34; + pubKey[5] = 0x56; + pubKey[6] = 0x78; + pubKey[7] = 0x9A; + pubKey[8] = 0xBC; + pubKey[9] = 0xDE; + pubKey[10] = 0xF0; + pubKey[11] = 0x11; + pubKey[12] = 0x22; + pubKey[13] = 0x33; + pubKey[14] = 0x44; + // bytes 15–31 are zero: that is 17 zeros (indices 15..31 inclusive) + final frame = _buildContactFrame(pubKey: pubKey); + final contact = Contact.fromFrame(frame); + expect(contact, isNull); + }); + + test('valid public key (few zeros) → returns Contact', () { + // Only 1 zero → well below the threshold + final pubKey = Uint8List.fromList(List.generate(32, (i) => i + 1)); + pubKey[5] = 0; // one zero byte + final frame = _buildContactFrame(pubKey: pubKey); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + }); + + test('name with all non-printable characters → returns null', () { + // Build frame with a name composed entirely of control characters (< 0x20) + final nameBytes = Uint8List(32); + nameBytes[0] = 0x01; + nameBytes[1] = 0x02; + nameBytes[2] = 0x03; + // remaining are 0x00 (null terminator ends the string after index 2, + // so readCStringGreedy returns a 3-char string of non-printables) + final writer = BytesBuilder(); + writer.addByte(respCodeContact); + writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1))); + writer.addByte(1); // type + writer.addByte(0); // flags + writer.addByte(0); // pathLen + writer.add(Uint8List(64)); // path + writer.add(nameBytes); + writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp + writer.add(Uint8List(4)); // lat + writer.add(Uint8List(4)); // lon + final frame = Uint8List.fromList(writer.toBytes()); + final contact = Contact.fromFrame(frame); + expect(contact, isNull); + }); + + test('name with valid printable characters → returns Contact', () { + final frame = _buildContactFrame(name: 'Alice'); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + expect(contact!.name, equals('Alice')); + }); + + test( + 'name with mix of printable and replacement chars → returns Contact (not all bad)', + () { + // Build a name with mostly printable chars and one replacement char (0xFFFD in codeUnits). + // utf8 allowMalformed: true maps invalid sequences to U+FFFD. + // We embed one invalid UTF-8 byte (0x80) among valid ASCII bytes. + // The decoded string will be "Hi\uFFFDThere" — not ALL bad, so should be accepted. + final nameBytes = Uint8List(32); + nameBytes[0] = 0x48; // 'H' + nameBytes[1] = 0x69; // 'i' + nameBytes[2] = 0x80; // invalid UTF-8 → decoded as U+FFFD + nameBytes[3] = 0x54; // 'T' + nameBytes[4] = 0x68; // 'h' + nameBytes[5] = 0x65; // 'e' + nameBytes[6] = 0x72; // 'r' + nameBytes[7] = 0x65; // 'e' + // rest are 0x00 (null terminator) + final writer = BytesBuilder(); + writer.addByte(respCodeContact); + writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1))); + writer.addByte(1); // type + writer.addByte(0); // flags + writer.addByte(0); // pathLen + writer.add(Uint8List(64)); // path + writer.add(nameBytes); + writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp + writer.add(Uint8List(4)); // lat + writer.add(Uint8List(4)); // lon + final frame = Uint8List.fromList(writer.toBytes()); + final contact = Contact.fromFrame(frame); + expect(contact, isNotNull); + }, + ); + }); + + group('PathRecord — routeWeight field', () { + test('default routeWeight is 1.0', () { + final record = PathRecord( + hopCount: 2, + tripTimeMs: 500, + timestamp: DateTime(2024), + wasFloodDiscovery: false, + pathBytes: [0x01, 0x02], + successCount: 1, + failureCount: 0, + ); + expect(record.routeWeight, equals(1.0)); + }); + + test('custom routeWeight is preserved', () { + final record = PathRecord( + hopCount: 3, + tripTimeMs: 800, + timestamp: DateTime(2024), + wasFloodDiscovery: false, + pathBytes: [0x01], + successCount: 5, + failureCount: 2, + routeWeight: 3.5, + ); + expect(record.routeWeight, equals(3.5)); + }); + + test('toJson includes route_weight', () { + final record = PathRecord( + hopCount: 1, + tripTimeMs: 200, + timestamp: DateTime(2024), + wasFloodDiscovery: true, + pathBytes: [], + successCount: 0, + failureCount: 0, + routeWeight: 2.25, + ); + final json = record.toJson(); + expect(json.containsKey('route_weight'), isTrue); + expect(json['route_weight'], equals(2.25)); + }); + + test('fromJson reads route_weight', () { + final json = { + 'hop_count': 2, + 'trip_time_ms': 400, + 'timestamp': DateTime(2024).toIso8601String(), + 'was_flood': false, + 'path_bytes': [1, 2, 3], + 'success_count': 3, + 'failure_count': 1, + 'route_weight': 4.0, + }; + final record = PathRecord.fromJson(json); + expect(record.routeWeight, equals(4.0)); + }); + + test('fromJson with missing route_weight defaults to 1.0 (backward compat)', + () { + final json = { + 'hop_count': 1, + 'trip_time_ms': 100, + 'timestamp': DateTime(2024).toIso8601String(), + 'was_flood': false, + 'path_bytes': [], + 'success_count': 0, + 'failure_count': 0, + // 'route_weight' intentionally omitted + }; + final record = PathRecord.fromJson(json); + expect(record.routeWeight, equals(1.0)); + }); + }); + + group('AppSettings — new fields', () { + test('default values are correct', () { + final settings = AppSettings(); + expect(settings.maxRouteWeight, equals(5.0)); + expect(settings.initialRouteWeight, equals(3.0)); + expect(settings.routeWeightSuccessIncrement, equals(0.5)); + expect(settings.routeWeightFailureDecrement, equals(0.2)); + expect(settings.maxMessageRetries, equals(5)); + }); + + test('toJson includes all new fields', () { + final settings = AppSettings(); + final json = settings.toJson(); + expect(json.containsKey('max_route_weight'), isTrue); + expect(json.containsKey('initial_route_weight'), isTrue); + expect(json.containsKey('route_weight_success_increment'), isTrue); + expect(json.containsKey('route_weight_failure_decrement'), isTrue); + expect(json.containsKey('max_message_retries'), isTrue); + expect(json['max_route_weight'], equals(5.0)); + expect(json['initial_route_weight'], equals(3.0)); + expect(json['route_weight_success_increment'], equals(0.5)); + expect(json['route_weight_failure_decrement'], equals(0.2)); + expect(json['max_message_retries'], equals(5)); + }); + + test('fromJson reads all new fields', () { + final json = { + 'max_route_weight': 10.0, + 'initial_route_weight': 2.0, + 'route_weight_success_increment': 1.0, + 'route_weight_failure_decrement': 1.5, + 'max_message_retries': 8, + }; + final settings = AppSettings.fromJson(json); + expect(settings.maxRouteWeight, equals(10.0)); + expect(settings.initialRouteWeight, equals(2.0)); + expect(settings.routeWeightSuccessIncrement, equals(1.0)); + expect(settings.routeWeightFailureDecrement, equals(1.5)); + expect(settings.maxMessageRetries, equals(8)); + }); + + test( + 'fromJson with missing new fields uses defaults (backward compat)', + () { + // Simulate an old settings JSON with none of the new fields + final json = {}; + final settings = AppSettings.fromJson(json); + expect(settings.maxRouteWeight, equals(5.0)); + expect(settings.initialRouteWeight, equals(3.0)); + expect(settings.routeWeightSuccessIncrement, equals(0.5)); + expect(settings.routeWeightFailureDecrement, equals(0.2)); + expect(settings.maxMessageRetries, equals(5)); + }, + ); + + test('copyWith works for maxRouteWeight', () { + final settings = AppSettings(); + final updated = settings.copyWith(maxRouteWeight: 8.0); + expect(updated.maxRouteWeight, equals(8.0)); + // Other fields should be unchanged + expect(updated.initialRouteWeight, equals(settings.initialRouteWeight)); + expect(updated.maxMessageRetries, equals(settings.maxMessageRetries)); + }); + + test('copyWith works for initialRouteWeight', () { + final settings = AppSettings(); + final updated = settings.copyWith(initialRouteWeight: 3.0); + expect(updated.initialRouteWeight, equals(3.0)); + expect(updated.maxRouteWeight, equals(settings.maxRouteWeight)); + }); + + test('copyWith works for routeWeightSuccessIncrement', () { + final settings = AppSettings(); + final updated = settings.copyWith(routeWeightSuccessIncrement: 0.25); + expect(updated.routeWeightSuccessIncrement, equals(0.25)); + expect( + updated.routeWeightFailureDecrement, + equals(settings.routeWeightFailureDecrement), + ); + }); + + test('copyWith works for routeWeightFailureDecrement', () { + final settings = AppSettings(); + final updated = settings.copyWith(routeWeightFailureDecrement: 0.75); + expect(updated.routeWeightFailureDecrement, equals(0.75)); + expect( + updated.routeWeightSuccessIncrement, + equals(settings.routeWeightSuccessIncrement), + ); + }); + + test('copyWith works for maxMessageRetries', () { + final settings = AppSettings(); + final updated = settings.copyWith(maxMessageRetries: 10); + expect(updated.maxMessageRetries, equals(10)); + expect(updated.maxRouteWeight, equals(settings.maxRouteWeight)); + }); + }); +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart new file mode 100644 index 0000000..561bad3 --- /dev/null +++ b/test/services/path_history_service_test.dart @@ -0,0 +1,815 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/path_history.dart'; +import 'package:meshcore_open/models/path_selection.dart'; +import 'package:meshcore_open/services/path_history_service.dart'; +import 'package:meshcore_open/services/storage_service.dart'; + +// --------------------------------------------------------------------------- +// Fake storage — no SharedPreferences dependency, all in-memory. +// --------------------------------------------------------------------------- +class FakeStorageService extends StorageService { + final Map _store = {}; + + @override + Future savePathHistory( + String contactPubKeyHex, + ContactPathHistory history, + ) async { + _store[contactPubKeyHex] = history; + } + + @override + Future loadPathHistory(String contactPubKeyHex) async { + return _store[contactPubKeyHex]; + } + + @override + Future clearPathHistory(String contactPubKeyHex) async { + _store.remove(contactPubKeyHex); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Build a minimal Contact with the given pubKeyHex, pathLength, and path. +/// +/// [publicKeyHex] must be exactly 64 hex characters (32 bytes). +Contact _makeContact({ + required String publicKeyHex, + int pathLength = -1, + List path = const [], +}) { + assert(publicKeyHex.length == 64, 'publicKeyHex must be 64 chars'); + final bytes = Uint8List(32); + for (int i = 0; i < 32; i++) { + bytes[i] = int.parse(publicKeyHex.substring(i * 2, i * 2 + 2), radix: 16); + } + return Contact( + publicKey: bytes, + name: 'Test', + type: 1, + pathLength: pathLength, + path: Uint8List.fromList(path), + lastSeen: DateTime.now(), + ); +} + +/// A 64-char hex string derived from a short tag (padded with zeros). +String _hex(String tag) { + // Convert tag to hex-safe characters, then pad + final hexTag = tag.codeUnits + .map((c) => c.toRadixString(16).padLeft(2, '0')) + .join(); + return hexTag.padLeft(64, '0'); +} + +/// Flush the microtask / async queue so that deferred storage loads complete. +Future _flush() async { + await Future.delayed(Duration.zero); +} + +/// Seed the service's cache for [pubKeyHex] by adding one path record and +/// waiting for the async storage-load path to complete. +/// +/// Call this before making synchronous assertions on a contact that has never +/// been seen by the service. +Future _seed( + PathHistoryService svc, + String pubKeyHex, { + List pathBytes = const [1], + int hopCount = 1, + double weight = 1.0, +}) async { + final contact = _makeContact( + publicKeyHex: pubKeyHex, + pathLength: hopCount, + path: pathBytes, + ); + svc.handlePathUpdated(contact, initialWeight: weight); + await _flush(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + late FakeStorageService storage; + late PathHistoryService svc; + + setUp(() { + storage = FakeStorageService(); + svc = PathHistoryService(storage); + }); + + group('path selection', () { + test('empty path history returns flood', () { + const pubKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + final selection = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(selection.useFlood, isTrue); + }); + + test('returns flood when maxRetries == 0', () { + const pubKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + final selection = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 0, + ); + expect(selection.useFlood, isTrue); + }); + + test('single known path is used for non-final attempts', () async { + final pubKey = _hex('aabb'); + await _seed(svc, pubKey, pathBytes: [0x01, 0x02], hopCount: 2); + + for (int i = 0; i < 4; i++) { + final selection = svc.selectPathForAttempt( + pubKey, + attemptIndex: i, + maxRetries: 5, + ); + expect(selection.useFlood, isFalse, reason: 'attempt $i should be path'); + expect(selection.pathBytes, equals([0x01, 0x02])); + } + }); + + test( + 'retries avoid immediately repeating the same path when possible', + () async { + final pubKey = _hex('rot1'); + await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0xBB], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.0, + ); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + final second = svc.selectPathForAttempt( + pubKey, + attemptIndex: 1, + maxRetries: 5, + recentSelections: [first], + ); + + expect(first.useFlood, isFalse); + expect(second.useFlood, isFalse); + expect(second.pathBytes, isNot(equals(first.pathBytes))); + }, + ); + + test( + 'retries avoid the last two paths when a third option exists', + () async { + final pubKey = _hex('rot2'); + await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 3.0); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false), + success: true, + successIncrement: 1.0, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.0, + ); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + final second = svc.selectPathForAttempt( + pubKey, + attemptIndex: 1, + maxRetries: 5, + recentSelections: [first], + ); + final third = svc.selectPathForAttempt( + pubKey, + attemptIndex: 2, + maxRetries: 5, + recentSelections: [first, second], + ); + + final chosenPaths = [ + first.pathBytes, + second.pathBytes, + third.pathBytes, + ]; + expect( + chosenPaths + .map((path) => path.map((b) => b.toRadixString(16)).join(',')) + .toSet() + .length, + equals(3), + ); + expect( + chosenPaths, + everyElement(anyOf(equals([0xA1]), equals([0xB2]), equals([0xC3]))), + ); + }, + ); + + test('first-attempt selection rotates across ranked candidates', () async { + final pubKey = _hex('rot3'); + await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 4.0); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false), + success: true, + successIncrement: 1.0, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.5, + ); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + final second = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + final third = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + + expect(first.pathBytes, isNot(equals(second.pathBytes))); + expect(second.pathBytes, isNot(equals(third.pathBytes))); + expect( + [first.pathBytes, second.pathBytes, third.pathBytes] + .map((path) => path.map((b) => b.toRadixString(16)).join(',')) + .toSet() + .length, + equals(3), + ); + }); + + test('final attempt is always flood regardless of known paths', () async { + final pubKey = _hex('ef01'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1); + + for (final retries in [1, 2, 5, 10]) { + final lastAttempt = svc.selectPathForAttempt( + pubKey, + attemptIndex: retries - 1, + maxRetries: retries, + ); + expect( + lastAttempt.useFlood, + isTrue, + reason: 'maxRetries=$retries: last attempt must be flood', + ); + } + }); + }); + + group('path scoring', () { + test('higher reliability beats higher route weight', () async { + final pubKey = _hex('rank1'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.5); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.1, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.1, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.0, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.0, + ); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(first.pathBytes, equals([0x02])); + }); + + test('lower latency wins when reliability is tied', () async { + final pubKey = _hex('rank2'); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x10], hopCount: 1, useFlood: false), + success: true, + tripTimeMs: 1200, + successIncrement: 0.0, + ); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x20], hopCount: 1, useFlood: false), + success: true, + tripTimeMs: 400, + successIncrement: 0.0, + ); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(first.pathBytes, equals([0x20])); + }); + + test('fresher path wins when reliability and latency are tied', () async { + final pubKey = _hex('rank3'); + final oldTimestamp = DateTime.now().subtract(const Duration(days: 10)); + final newTimestamp = DateTime.now().subtract(const Duration(hours: 1)); + storage._store[pubKey] = ContactPathHistory( + contactPubKeyHex: pubKey, + recentPaths: [ + PathRecord( + hopCount: 1, + tripTimeMs: 900, + timestamp: oldTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x01], + successCount: 1, + failureCount: 0, + routeWeight: 1.0, + ), + PathRecord( + hopCount: 1, + tripTimeMs: 900, + timestamp: newTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x02], + successCount: 1, + failureCount: 0, + routeWeight: 1.0, + ), + ], + ); + svc.getRecentPaths(pubKey); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(first.pathBytes, equals([0x02])); + }); + + test('higher route weight wins when other factors are effectively tied', () async { + final pubKey = _hex('rank4'); + final sharedTimestamp = + DateTime.now().subtract(const Duration(minutes: 30)); + storage._store[pubKey] = ContactPathHistory( + contactPubKeyHex: pubKey, + recentPaths: [ + PathRecord( + hopCount: 1, + tripTimeMs: 750, + timestamp: sharedTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x01], + successCount: 1, + failureCount: 0, + routeWeight: 4.0, + ), + PathRecord( + hopCount: 1, + tripTimeMs: 750, + timestamp: sharedTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x02], + successCount: 1, + failureCount: 0, + routeWeight: 1.0, + ), + ], + ); + svc.getRecentPaths(pubKey); + await _flush(); + + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(first.pathBytes, equals([0x01])); + }); + }); + + // ------------------------------------------------------------------------- + // Group 3: recordPathResult — weight adjustment + // ------------------------------------------------------------------------- + group('recordPathResult weight adjustment', () { + test('success increments weight by successIncrement', () async { + final pubKey = _hex('w001'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.5, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths, isNotEmpty); + expect(paths.first.routeWeight, closeTo(1.5, 0.001)); + expect(paths.first.timestamp, isNotNull); + }); + + test('attempts do not set timestamp before first success', () async { + final pubKey = _hex('w000'); + + svc.recordPathAttempt( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths, isNotEmpty); + expect(paths.first.successCount, equals(0)); + expect(paths.first.timestamp, isNull); + }); + + test('failure preserves the last success timestamp', () async { + final pubKey = _hex('w006'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.0, + ); + await _flush(); + final successTimestamp = svc.getRecentPaths(pubKey).first.timestamp; + + await Future.delayed(const Duration(milliseconds: 5)); + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.1, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.timestamp, equals(successTimestamp)); + }); + + test('success clamps at maxWeight', () async { + final pubKey = _hex('w002'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: true, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.routeWeight, closeTo(5.0, 0.001)); + }); + + test('failure decrements weight', () async { + final pubKey = _hex('w003'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 2.0); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.5, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.routeWeight, closeTo(1.5, 0.001)); + }); + + test('failure to 0 removes the path', () async { + final pubKey = _hex('w004'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3); + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false), + success: false, + failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect( + paths.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0x01), + isFalse, + reason: 'path with weight <= 0 should have been removed', + ); + }); + + test( + 'flood result does not affect path records, updates floodStats', + () async { + final pubKey = _hex('w005'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + final pathsBefore = svc.getRecentPaths(pubKey); + final weightBefore = pathsBefore.first.routeWeight; + + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [], hopCount: -1, useFlood: true), + success: true, + tripTimeMs: 1234, + ); + await _flush(); + + // Path records should be unchanged. + final pathsAfter = svc.getRecentPaths(pubKey); + expect(pathsAfter.first.routeWeight, equals(weightBefore)); + + // Flood stats should be updated. + final stats = svc.getFloodStats(pubKey); + expect(stats, isNotNull); + expect(stats!.successCount, equals(1)); + expect(stats.lastTripTimeMs, equals(1234)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // Group 4: handlePathUpdated + // ------------------------------------------------------------------------- + group('handlePathUpdated', () { + test( + 'pathLength >= 0 with path bytes → records path using pathLength', + () async { + final pubKey = _hex('h001'); + final contact = _makeContact( + publicKeyHex: pubKey, + pathLength: 3, + path: [0x01, 0x02, 0x03], + ); + + svc.handlePathUpdated(contact); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths, isNotEmpty); + expect(paths.first.hopCount, equals(3)); + expect(paths.first.pathBytes, equals([0x01, 0x02, 0x03])); + }, + ); + + test( + 'pathLength < 0 with path bytes → records path using path.length as hopCount', + () async { + final pubKey = _hex('h002'); + final contact = _makeContact( + publicKeyHex: pubKey, + pathLength: -1, // flood indicator from firmware + path: [0xAA, 0xBB], + ); + + svc.handlePathUpdated(contact); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths, isNotEmpty); + // hopCount should equal path.length (2), not pathLength (-1). + expect(paths.first.hopCount, equals(2)); + expect(paths.first.pathBytes, equals([0xAA, 0xBB])); + }, + ); + + test('pathLength < 0 with empty path → skipped (returns early)', () async { + final pubKey = _hex('h003'); + final contact = _makeContact( + publicKeyHex: pubKey, + pathLength: -1, + path: [], + ); + + svc.handlePathUpdated(contact); + await _flush(); + + // Nothing should have been recorded. + final paths = svc.getRecentPaths(pubKey); + expect(paths, isEmpty); + }); + + test('initialWeight is applied to the new record', () async { + final pubKey = _hex('h004'); + final contact = _makeContact( + publicKeyHex: pubKey, + pathLength: 1, + path: [0x55], + ); + + svc.handlePathUpdated(contact, initialWeight: 2.5); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.routeWeight, closeTo(2.5, 0.001)); + }); + }); + + // ------------------------------------------------------------------------- + // Group 5: recordFloodPathAttribution + // ------------------------------------------------------------------------- + group('recordFloodPathAttribution', () { + test('credits existing path with success increment', () async { + final pubKey = _hex('fa01'); + await _seed( + svc, + pubKey, + pathBytes: [0x01, 0x02], + hopCount: 2, + weight: 1.0, + ); + + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [0x01, 0x02], + hopCount: 2, + tripTimeMs: 3000, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + final credited = paths.firstWhere( + (p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0x01, + ); + expect(credited.routeWeight, closeTo(1.5, 0.001)); + expect(credited.successCount, equals(1)); + expect(credited.tripTimeMs, equals(3000)); + }); + + test('creates new path record when path is unknown', () async { + final pubKey = _hex('fa02'); + // Seed with a different path so the cache is warm. + await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0); + + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [0xBB, 0xCC], + hopCount: 2, + tripTimeMs: 2000, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + final newPath = paths.firstWhere( + (p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0xBB, + ); + // New path: weight = 1.0 (default) + 0.5 = 1.5 + expect(newPath.routeWeight, closeTo(1.5, 0.001)); + expect(newPath.successCount, equals(1)); + expect(newPath.wasFloodDiscovery, isTrue); + }); + + test('clamps weight at maxWeight', () async { + final pubKey = _hex('fa03'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8); + + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [0x01], + hopCount: 1, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.routeWeight, closeTo(5.0, 0.001)); + }); + + test('ignores empty pathBytes', () async { + final pubKey = _hex('fa04'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + final pathsBefore = svc.getRecentPaths(pubKey); + final weightBefore = pathsBefore.first.routeWeight; + + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [], + hopCount: 0, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + // Existing path should be untouched. + final pathsAfter = svc.getRecentPaths(pubKey); + expect(pathsAfter.first.routeWeight, equals(weightBefore)); + }); + + test('ignores negative hopCount (flood indicator)', () async { + final pubKey = _hex('fa05'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + final pathsBefore = svc.getRecentPaths(pubKey); + final weightBefore = pathsBefore.first.routeWeight; + + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [0x01], + hopCount: -1, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + final pathsAfter = svc.getRecentPaths(pubKey); + expect(pathsAfter.first.routeWeight, equals(weightBefore)); + }); + + test('flood stats still recorded independently', () async { + final pubKey = _hex('fa06'); + await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0); + + // Record a flood success (this updates flood stats). + svc.recordPathResult( + pubKey, + const PathSelection(pathBytes: [], hopCount: -1, useFlood: true), + success: true, + tripTimeMs: 5000, + ); + + // Then attribute the flood success to a path. + svc.recordFloodPathAttribution( + contactPubKeyHex: pubKey, + pathBytes: [0x01], + hopCount: 1, + tripTimeMs: 5000, + successIncrement: 0.5, + maxWeight: 5.0, + ); + await _flush(); + + // Both flood stats and path attribution should exist. + final stats = svc.getFloodStats(pubKey); + expect(stats, isNotNull); + expect(stats!.successCount, equals(1)); + + final paths = svc.getRecentPaths(pubKey); + expect(paths.first.routeWeight, closeTo(1.5, 0.001)); + }); + }); +} diff --git a/test/services/retry_and_protocol_test.dart b/test/services/retry_and_protocol_test.dart new file mode 100644 index 0000000..b58da45 --- /dev/null +++ b/test/services/retry_and_protocol_test.dart @@ -0,0 +1,628 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/message.dart'; +import 'package:meshcore_open/services/message_retry_service.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash] +/// so tests can cross-check without calling the real implementation twice. +Uint8List _manualAckHash( + int timestampSeconds, + int attemptMasked, // already masked to 0x03 + String text, + Uint8List senderPubKey, +) { + final textBytes = utf8.encode(text); + final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length); + int offset = 0; + + buffer[offset++] = timestampSeconds & 0xFF; + buffer[offset++] = (timestampSeconds >> 8) & 0xFF; + buffer[offset++] = (timestampSeconds >> 16) & 0xFF; + buffer[offset++] = (timestampSeconds >> 24) & 0xFF; + buffer[offset++] = attemptMasked & 0xFF; + + buffer.setRange(offset, offset + textBytes.length, textBytes); + offset += textBytes.length; + buffer.setRange(offset, offset + senderPubKey.length, senderPubKey); + + final hash = sha256.convert(buffer); + return Uint8List.fromList(hash.bytes.sublist(0, 4)); +} + +Uint8List _makeKey(int seed) { + final key = Uint8List(32); + for (int i = 0; i < 32; i++) { + key[i] = (seed + i) & 0xFF; + } + return key; +} + +Uint8List _makeRecipientKey() { + final key = Uint8List(32); + for (int i = 0; i < 32; i++) { + key[i] = (0xAA + i) & 0xFF; + } + return key; +} + +Contact _makeContact({ + required Uint8List publicKey, + int pathLength = -1, + List path = const [], +}) { + return Contact( + publicKey: publicKey, + name: 'Test', + type: 1, + pathLength: pathLength, + path: Uint8List.fromList(path), + lastSeen: DateTime.now(), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // Fixed inputs reused across groups + const int fixedTs = 1700000000; + const String fixedText = 'Hello mesh'; + final Uint8List fixedKey = _makeKey(0x11); + final Uint8List recipientKey = _makeRecipientKey(); + + // ------------------------------------------------------------------------- + group('computeExpectedAckHash — attempt masking', () { + test('attempts 0–3 all produce different hashes', () { + final hashes = List.generate( + 4, + (i) => MessageRetryService.computeExpectedAckHash( + fixedTs, + i, + fixedText, + fixedKey, + ), + ); + + // All four must be pairwise distinct + for (int i = 0; i < hashes.length; i++) { + for (int j = i + 1; j < hashes.length; j++) { + expect( + hashes[i], + isNot(equals(hashes[j])), + reason: 'attempt $i and attempt $j should produce different hashes', + ); + } + } + }); + + test('attempt 4 produces same hash as attempt 0 (4 & 0x03 == 0)', () { + final hash0 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + fixedKey, + ); + final hash4 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 4, + fixedText, + fixedKey, + ); + expect(hash4, equals(hash0)); + }); + + test('attempt 5 produces same hash as attempt 1 (5 & 0x03 == 1)', () { + final hash1 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 1, + fixedText, + fixedKey, + ); + final hash5 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 5, + fixedText, + fixedKey, + ); + expect(hash5, equals(hash1)); + }); + + test('attempt 7 produces same hash as attempt 3 (7 & 0x03 == 3)', () { + final hash3 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 3, + fixedText, + fixedKey, + ); + final hash7 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 7, + fixedText, + fixedKey, + ); + expect(hash7, equals(hash3)); + }); + + test('same inputs always produce the same hash (deterministic)', () { + final first = MessageRetryService.computeExpectedAckHash( + fixedTs, + 2, + fixedText, + fixedKey, + ); + final second = MessageRetryService.computeExpectedAckHash( + fixedTs, + 2, + fixedText, + fixedKey, + ); + expect(first, equals(second)); + }); + + test('hash is exactly 4 bytes long', () { + final hash = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + fixedKey, + ); + expect(hash.length, equals(4)); + }); + + test('hash matches manual SHA-256 computation', () { + for (int attempt = 0; attempt < 4; attempt++) { + final actual = MessageRetryService.computeExpectedAckHash( + fixedTs, + attempt, + fixedText, + fixedKey, + ); + final expected = _manualAckHash(fixedTs, attempt, fixedText, fixedKey); + expect( + actual, + equals(expected), + reason: 'mismatch at attempt $attempt', + ); + } + }); + + test('different timestamps produce different hashes', () { + final hashA = MessageRetryService.computeExpectedAckHash( + 1700000000, + 0, + fixedText, + fixedKey, + ); + final hashB = MessageRetryService.computeExpectedAckHash( + 1700000001, + 0, + fixedText, + fixedKey, + ); + expect(hashA, isNot(equals(hashB))); + }); + + test('different texts produce different hashes', () { + final hashA = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + 'Hello mesh', + fixedKey, + ); + final hashB = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + 'Hello mesh!', + fixedKey, + ); + expect(hashA, isNot(equals(hashB))); + }); + + test('different sender keys produce different hashes', () { + final keyA = _makeKey(0x01); + final keyB = _makeKey(0x02); + final hashA = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + keyA, + ); + final hashB = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + keyB, + ); + expect(hashA, isNot(equals(hashB))); + }); + }); + + // ------------------------------------------------------------------------- + group('buildSendTextMsgFrame — attempt encoding', () { + // Frame layout: [cmd(1)][txtType(1)][attempt(1)][timestamp(4)][pubKeyPrefix(6)][text][null(1)] + // So byte index 2 carries the raw attempt & 0xFF. + + test('attempt 0 → byte[2] is 0', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(0)); + }); + + test('attempt 3 → byte[2] is 3', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 3, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(3)); + }); + + test('attempt 4 → byte[2] is 4 (raw value, not clamped to 3)', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 4, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(4)); + }); + + test('attempt 255 → byte[2] is 255', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 255, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(255)); + }); + + test('attempt 256 → byte[2] is 255 (clamped, not wrapped)', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 256, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(255)); + }); + + test('byte[0] is cmdSendTxtMsg (2)', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + expect(frame[0], equals(cmdSendTxtMsg)); + }); + + test('byte[1] is txtTypePlain (0)', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + expect(frame[1], equals(txtTypePlain)); + }); + + test('timestamp bytes[3..6] are little-endian encoded', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + final decoded = + frame[3] | (frame[4] << 8) | (frame[5] << 16) | (frame[6] << 24); + expect(decoded, equals(fixedTs)); + }); + + test( + 'pub key prefix (bytes 7..12) matches first 6 bytes of recipient key', + () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + expect(frame.sublist(7, 13), equals(recipientKey.sublist(0, 6))); + }, + ); + + test('frame is null-terminated after text', () { + final frame = buildSendTextMsgFrame( + recipientKey, + 'hi', + attempt: 0, + timestampSeconds: fixedTs, + ); + expect(frame.last, equals(0)); + }); + }); + + // ------------------------------------------------------------------------- + group( + 'ACK hash consistency between computeExpectedAckHash and firmware behavior', + () { + // The firmware reads the raw attempt byte from the frame, then masks it + // with & 3 when computing the ACK hash. Flutter does the same masking + // inside computeExpectedAckHash. So the two sides must agree. + + test('attempt 4: flutter hash (4 & 3 = 0) equals hash for attempt 0', () { + // Flutter sends raw byte 4 in the frame, but computes hash with 4&3=0. + // Firmware reads 4, masks to 0, computes same hash → they match. + final hashFor4 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 4, + fixedText, + fixedKey, + ); + final hashFor0 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + fixedKey, + ); + expect(hashFor4, equals(hashFor0)); + + // Also confirm the frame byte is raw 4, not 0 + final frame = buildSendTextMsgFrame( + recipientKey, + fixedText, + attempt: 4, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(4), reason: 'frame carries raw attempt byte'); + }); + + test( + 'attempt 3: flutter hash equals hash computed directly for attempt 3', + () { + // 3 & 3 == 3, so no wrapping — both sides agree. + final hashFor3 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 3, + fixedText, + fixedKey, + ); + final hashFor3Direct = _manualAckHash( + fixedTs, + 3, + fixedText, + fixedKey, + ); + expect(hashFor3, equals(hashFor3Direct)); + + final frame = buildSendTextMsgFrame( + recipientKey, + fixedText, + attempt: 3, + timestampSeconds: fixedTs, + ); + expect(frame[2], equals(3)); + }, + ); + + test( + 'attempt 3 and attempt 4 produce DIFFERENT hashes (3&3=3 vs 4&3=0)', + () { + final hash3 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 3, + fixedText, + fixedKey, + ); + final hash4 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 4, + fixedText, + fixedKey, + ); + expect(hash3, isNot(equals(hash4))); + }, + ); + + test('attempt 8 (8&3=0) produces the same hash as attempt 0', () { + final hash8 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 8, + fixedText, + fixedKey, + ); + final hash0 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + fixedKey, + ); + expect(hash8, equals(hash0)); + }); + + test( + 'hash cycle repeats every 4 attempts (modular arithmetic holds)', + () { + for (int base = 0; base < 4; base++) { + final hashBase = MessageRetryService.computeExpectedAckHash( + fixedTs, + base, + fixedText, + fixedKey, + ); + final hashPlus4 = MessageRetryService.computeExpectedAckHash( + fixedTs, + base + 4, + fixedText, + fixedKey, + ); + final hashPlus8 = MessageRetryService.computeExpectedAckHash( + fixedTs, + base + 8, + fixedText, + fixedKey, + ); + expect( + hashPlus4, + equals(hashBase), + reason: 'attempt ${base + 4} should match attempt $base', + ); + expect( + hashPlus8, + equals(hashBase), + reason: 'attempt ${base + 8} should match attempt $base', + ); + } + }, + ); + }, + ); + + // ------------------------------------------------------------------------- + group('_AckHashMapping.attemptIndex — indirect verification via public API', () { + // _AckHashMapping is private; we validate its purpose indirectly: that + // computeExpectedAckHash records the correct per-attempt hash so that the + // right hash is matched when an ACK arrives. + + test('each attempt index 0–3 produces a distinct 4-byte hash', () { + final hashes = {}; + for (int attempt = 0; attempt < 4; attempt++) { + final hash = MessageRetryService.computeExpectedAckHash( + fixedTs, + attempt, + fixedText, + fixedKey, + ); + final hex = hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + expect( + hashes.containsKey(hex), + isFalse, + reason: 'attempt $attempt collides with attempt ${hashes[hex]}', + ); + hashes[hex] = attempt; + } + expect(hashes.length, equals(4)); + }); + + test( + 'attempt index wraps: hash for attempt 4 matches stored hash for attempt 0', + () { + final storedHash = MessageRetryService.computeExpectedAckHash( + fixedTs, + 0, + fixedText, + fixedKey, + ); + // Simulates firmware reading raw attempt=4 and masking to 0 for hash. + final firmwareComputedHash = _manualAckHash( + fixedTs, + 4 & 0x03, // firmware masks here + fixedText, + fixedKey, + ); + expect(firmwareComputedHash, equals(storedHash)); + }, + ); + + test( + 'attempt index 1 and 5 map to the same slot — ACK from either retry is matched', + () { + final hashForAttempt1 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 1, + fixedText, + fixedKey, + ); + final hashForAttempt5 = MessageRetryService.computeExpectedAckHash( + fixedTs, + 5, + fixedText, + fixedKey, + ); + // Both should produce the identical bytes, confirming the service + // would record and match the correct attempt index. + expect(hashForAttempt5, equals(hashForAttempt1)); + }, + ); + }); + + group('sendMessageWithRetry — auto path fallback', () { + test( + 'preserves the contact path when auto-selection returns null', + () async { + final retryService = MessageRetryService(); + Message? addedMessage; + final contact = _makeContact( + publicKey: recipientKey, + pathLength: 2, + path: const [0x10, 0x20], + ); + + retryService.initialize( + RetryServiceConfig( + sendMessage: (_, _, _, _) {}, + addMessage: (_, message) => addedMessage = message, + updateMessage: (_) {}, + clearContactPath: (_) {}, + setContactPath: (_, _, _) {}, + selectRetryPath: (_, _, _, _) => null, + ), + ); + + await retryService.sendMessageWithRetry( + contact: contact, + text: 'hello', + ); + + expect(addedMessage, isNotNull); + expect(addedMessage!.pathLength, equals(2)); + expect( + addedMessage!.pathBytes, + equals(Uint8List.fromList([0x10, 0x20])), + ); + }, + ); + + test('uses flood when contact is in flood mode', () async { + final retryService = MessageRetryService(); + Message? addedMessage; + final contact = _makeContact( + publicKey: recipientKey, + pathLength: -1, + path: const [], + ); + + retryService.initialize( + RetryServiceConfig( + sendMessage: (_, _, _, _) {}, + addMessage: (_, message) => addedMessage = message, + updateMessage: (_) {}, + clearContactPath: (_) {}, + setContactPath: (_, _, _) {}, + ), + ); + + await retryService.sendMessageWithRetry(contact: contact, text: 'hello'); + + expect(addedMessage, isNotNull); + expect(addedMessage!.pathLength, equals(-1)); + expect(addedMessage!.pathBytes, isEmpty); + }); + }); +} From 4ad4a93a207dc10f79b12aff7d15ee11305747a6 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Fri, 20 Mar 2026 01:55:08 -0700 Subject: [PATCH 38/75] formatted code --- lib/connector/meshcore_connector.dart | 94 +++++++++++--------- lib/services/message_retry_service.dart | 17 ++-- lib/services/path_history_service.dart | 26 +++--- test/models/model_changes_test.dart | 36 ++++---- test/services/path_history_service_test.dart | 86 ++++++++++-------- 5 files changed, 143 insertions(+), 116 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d00d49a..ef2f9b7 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -699,43 +699,44 @@ class MeshCoreConnector extends ChangeNotifier { _loadChannelOrder(); // Initialize retry service callbacks - _retryService?.initialize(RetryServiceConfig( - sendMessage: _sendMessageDirect, - addMessage: _addMessage, - updateMessage: _updateMessage, - clearContactPath: clearContactPath, - setContactPath: setContactPath, - calculateTimeout: - (pathLength, messageBytes, {String? contactKey}) => calculateTimeout( - pathLength: pathLength, - messageBytes: messageBytes, - contactKey: contactKey, - ), - getSelfPublicKey: () => _selfPublicKey, - prepareContactOutboundText: prepareContactOutboundText, - appSettingsService: appSettingsService, - debugLogService: _appDebugLogService, - recordPathResult: _recordPathResult, - selectRetryPath: - (contactKey, attemptIndex, maxRetries, recentSelections) => - _selectAutoPathForAttempt( - contactKey, - attemptIndex: attemptIndex, - maxRetries: maxRetries, - recentSelections: recentSelections, - ), - onDeliveryObserved: - (contactKey, pathLength, messageBytes, tripTimeMs) { - final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; - _timeoutPredictionService?.recordObservation( - contactKey: contactKey, + _retryService?.initialize( + RetryServiceConfig( + sendMessage: _sendMessageDirect, + addMessage: _addMessage, + updateMessage: _updateMessage, + clearContactPath: clearContactPath, + setContactPath: setContactPath, + calculateTimeout: (pathLength, messageBytes, {String? contactKey}) => + calculateTimeout( pathLength: pathLength, messageBytes: messageBytes, - tripTimeMs: tripTimeMs, - secondsSinceLastRx: secSinceRx, - ); - }, - )); + contactKey: contactKey, + ), + getSelfPublicKey: () => _selfPublicKey, + prepareContactOutboundText: prepareContactOutboundText, + appSettingsService: appSettingsService, + debugLogService: _appDebugLogService, + recordPathResult: _recordPathResult, + selectRetryPath: + (contactKey, attemptIndex, maxRetries, recentSelections) => + _selectAutoPathForAttempt( + contactKey, + attemptIndex: attemptIndex, + maxRetries: maxRetries, + recentSelections: recentSelections, + ), + onDeliveryObserved: (contactKey, pathLength, messageBytes, tripTimeMs) { + final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; + _timeoutPredictionService?.recordObservation( + contactKey: contactKey, + pathLength: pathLength, + messageBytes: messageBytes, + tripTimeMs: tripTimeMs, + secondsSinceLastRx: secSinceRx, + ); + }, + ), + ); final maxRetries = _appSettingsService?.settings.maxMessageRetries ?? 5; _retryService?.setMaxRetries(maxRetries); } @@ -908,7 +909,8 @@ class MeshCoreConnector extends ChangeNotifier { List recentSelections = const [], }) { final hasKnownPaths = - _pathHistoryService?.getRecentPaths(contactPubKeyHex).isNotEmpty ?? false; + _pathHistoryService?.getRecentPaths(contactPubKeyHex).isNotEmpty ?? + false; if (!hasKnownPaths) { return null; } @@ -3619,10 +3621,7 @@ class MeshCoreConnector extends ChangeNotifier { void _handleIncomingChannelMessage(Uint8List frame) { final parsed = ChannelMessage.fromFrame(frame); if (parsed != null && parsed.channelIndex != null) { - if (_shouldDropSelfChannelMessage( - parsed.senderName, - parsed.pathBytes, - )) { + if (_shouldDropSelfChannelMessage(parsed.senderName, parsed.pathBytes)) { return; } final contentHash = _computeContentHash( @@ -4246,7 +4245,9 @@ class MeshCoreConnector extends ChangeNotifier { final pathLenRaw = raw[index++]; final pathByteLen = _decodePathByteLen(pathLenRaw); if (raw.length < index + pathByteLen) return null; - final pathBytes = Uint8List.fromList(raw.sublist(index, index + pathByteLen)); + final pathBytes = Uint8List.fromList( + raw.sublist(index, index + pathByteLen), + ); index += pathByteLen; if (raw.length <= index) return null; final payload = Uint8List.fromList(raw.sublist(index)); @@ -4273,12 +4274,19 @@ class MeshCoreConnector extends ChangeNotifier { input[0] = payloadType; input.setRange(1, input.length, payload); final digest = crypto.sha256.convert(input).bytes; - return digest.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return digest + .sublist(0, 8) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); } /// Content-based dedup hash for sync queue messages (no raw payload available). /// Prefixed with 'c:' to avoid collisions with packet hashes. - String _computeContentHash(int channelIdx, int timestampSecs, String fullText) { + String _computeContentHash( + int channelIdx, + int timestampSecs, + String fullText, + ) { final textBytes = utf8.encode(fullText); final input = Uint8List(5 + textBytes.length); input[0] = channelIdx; diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 2f10511..b284425 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -22,7 +22,11 @@ class _AckHistoryEntry { } /// (messageId, timestamp, attemptIndex) — stored per ACK hash for O(1) lookup. -typedef AckHashMapping = ({String messageId, DateTime timestamp, int attemptIndex}); +typedef AckHashMapping = ({ + String messageId, + DateTime timestamp, + int attemptIndex, +}); class RetryServiceConfig { final void Function(Contact, String, int, int) sendMessage; @@ -31,7 +35,7 @@ class RetryServiceConfig { final Function(Contact)? clearContactPath; final Function(Contact, Uint8List, int)? setContactPath; final int Function(int pathLength, int messageBytes, {String? contactKey})? - calculateTimeout; + calculateTimeout; final Uint8List? Function()? getSelfPublicKey; final String Function(Contact, String)? prepareContactOutboundText; final AppSettingsService? appSettingsService; @@ -43,7 +47,8 @@ class RetryServiceConfig { int attemptIndex, int maxRetries, List recentSelections, - )? selectRetryPath; + )? + selectRetryPath; const RetryServiceConfig({ required this.sendMessage, @@ -132,7 +137,8 @@ class MessageRetryService extends ChangeNotifier { }) async { final messageId = const Uuid().v4(); final resolved = resolvePathSelection(contact); - final messagePathBytes = pathBytes ?? Uint8List.fromList(resolved.pathBytes); + final messagePathBytes = + pathBytes ?? Uint8List.fromList(resolved.pathBytes); final messagePathLength = pathLength ?? (resolved.useFlood ? -1 : resolved.hopCount); final message = Message( @@ -262,7 +268,8 @@ class MessageRetryService extends ChangeNotifier { if (config.setContactPath != null && config.clearContactPath != null) { final bool useFlood = currentSelection != null ? currentSelection.useFlood - : (effectiveMessage.pathLength != null && effectiveMessage.pathLength! < 0); + : (effectiveMessage.pathLength != null && + effectiveMessage.pathLength! < 0); final List pathBytes = currentSelection != null ? currentSelection.pathBytes : effectiveMessage.pathBytes; diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 809f867..68a9245 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -469,17 +469,18 @@ class PathHistoryService extends ChangeNotifier { final highestRouteWeight = _getHighestKnownRouteWeight(ranked); ranked.sort((a, b) { - final scoreCompare = _scorePathRecord( - b, - fastestTripMs: fastestTripMs, - highestRouteWeight: highestRouteWeight, - ).compareTo( - _scorePathRecord( - a, - fastestTripMs: fastestTripMs, - highestRouteWeight: highestRouteWeight, - ), - ); + final scoreCompare = + _scorePathRecord( + b, + fastestTripMs: fastestTripMs, + highestRouteWeight: highestRouteWeight, + ).compareTo( + _scorePathRecord( + a, + fastestTripMs: fastestTripMs, + highestRouteWeight: highestRouteWeight, + ), + ); if (scoreCompare != 0) { return scoreCompare; } @@ -531,8 +532,7 @@ class PathHistoryService extends ChangeNotifier { (DateTime.now().difference(path.timestamp!).inMinutes / 60.0 / 24.0)); - final routeWeight = - (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0); + final routeWeight = (path.routeWeight / highestRouteWeight).clamp(0.0, 1.0); return (reliability * 0.45) + (latency * 0.25) + diff --git a/test/models/model_changes_test.dart b/test/models/model_changes_test.dart index 165b91d..a80c794 100644 --- a/test/models/model_changes_test.dart +++ b/test/models/model_changes_test.dart @@ -15,7 +15,9 @@ Uint8List _buildContactFrame({ }) { final writer = BytesBuilder(); writer.addByte(respCodeContact); // 3 - writer.add(pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1))); // valid pubkey + writer.add( + pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1)), + ); // valid pubkey writer.addByte(1); // type writer.addByte(0); // flags writer.addByte(pathLen); @@ -239,21 +241,23 @@ void main() { expect(record.routeWeight, equals(4.0)); }); - test('fromJson with missing route_weight defaults to 1.0 (backward compat)', - () { - final json = { - 'hop_count': 1, - 'trip_time_ms': 100, - 'timestamp': DateTime(2024).toIso8601String(), - 'was_flood': false, - 'path_bytes': [], - 'success_count': 0, - 'failure_count': 0, - // 'route_weight' intentionally omitted - }; - final record = PathRecord.fromJson(json); - expect(record.routeWeight, equals(1.0)); - }); + test( + 'fromJson with missing route_weight defaults to 1.0 (backward compat)', + () { + final json = { + 'hop_count': 1, + 'trip_time_ms': 100, + 'timestamp': DateTime(2024).toIso8601String(), + 'was_flood': false, + 'path_bytes': [], + 'success_count': 0, + 'failure_count': 0, + // 'route_weight' intentionally omitted + }; + final record = PathRecord.fromJson(json); + expect(record.routeWeight, equals(1.0)); + }, + ); }); group('AppSettings — new fields', () { diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 561bad3..87ae729 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -140,7 +140,11 @@ void main() { attemptIndex: i, maxRetries: 5, ); - expect(selection.useFlood, isFalse, reason: 'attempt $i should be path'); + expect( + selection.useFlood, + isFalse, + reason: 'attempt $i should be path', + ); expect(selection.pathBytes, equals([0x01, 0x02])); } }); @@ -400,45 +404,49 @@ void main() { expect(first.pathBytes, equals([0x02])); }); - test('higher route weight wins when other factors are effectively tied', () async { - final pubKey = _hex('rank4'); - final sharedTimestamp = - DateTime.now().subtract(const Duration(minutes: 30)); - storage._store[pubKey] = ContactPathHistory( - contactPubKeyHex: pubKey, - recentPaths: [ - PathRecord( - hopCount: 1, - tripTimeMs: 750, - timestamp: sharedTimestamp, - wasFloodDiscovery: false, - pathBytes: const [0x01], - successCount: 1, - failureCount: 0, - routeWeight: 4.0, - ), - PathRecord( - hopCount: 1, - tripTimeMs: 750, - timestamp: sharedTimestamp, - wasFloodDiscovery: false, - pathBytes: const [0x02], - successCount: 1, - failureCount: 0, - routeWeight: 1.0, - ), - ], - ); - svc.getRecentPaths(pubKey); - await _flush(); + test( + 'higher route weight wins when other factors are effectively tied', + () async { + final pubKey = _hex('rank4'); + final sharedTimestamp = DateTime.now().subtract( + const Duration(minutes: 30), + ); + storage._store[pubKey] = ContactPathHistory( + contactPubKeyHex: pubKey, + recentPaths: [ + PathRecord( + hopCount: 1, + tripTimeMs: 750, + timestamp: sharedTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x01], + successCount: 1, + failureCount: 0, + routeWeight: 4.0, + ), + PathRecord( + hopCount: 1, + tripTimeMs: 750, + timestamp: sharedTimestamp, + wasFloodDiscovery: false, + pathBytes: const [0x02], + successCount: 1, + failureCount: 0, + routeWeight: 1.0, + ), + ], + ); + svc.getRecentPaths(pubKey); + await _flush(); - final first = svc.selectPathForAttempt( - pubKey, - attemptIndex: 0, - maxRetries: 5, - ); - expect(first.pathBytes, equals([0x01])); - }); + final first = svc.selectPathForAttempt( + pubKey, + attemptIndex: 0, + maxRetries: 5, + ); + expect(first.pathBytes, equals([0x01])); + }, + ); }); // ------------------------------------------------------------------------- From cb63b48b7831a5f2f971aeed2efd9e6dfeb8d443 Mon Sep 17 00:00:00 2001 From: zjs81 Date: Fri, 20 Mar 2026 02:24:02 -0700 Subject: [PATCH 39/75] Add comprehensive documentation for various app features - Introduced "Contacts" documentation detailing the contact management system, types, list, search, and tap actions. - Added "Map & Location" documentation covering map features, interactions, path tracing, and line-of-sight analysis. - Created "Navigation" documentation outlining app flow, QuickSwitchBar, and device screen interactions. - Developed "Notifications" documentation explaining notification types, in-app badges, settings, and rate limiting. - Established "Repeater Management" documentation for managing repeaters and room servers, including CLI access and telemetry. - Compiled "Scanner & Connection" documentation detailing BLE, USB, and TCP connection processes. - Formulated "Settings" documentation outlining access, layout, device info, app settings, node settings, actions, debug options, export features, and about section. --- documentation/README.md | 30 +++ documentation/additional-features.md | 187 ++++++++++++++++++ documentation/ble-protocol.md | 249 ++++++++++++++++++++++++ documentation/channels.md | 164 ++++++++++++++++ documentation/chat-and-messaging.md | 120 ++++++++++++ documentation/contacts.md | 118 +++++++++++ documentation/map-and-location.md | 186 ++++++++++++++++++ documentation/navigation.md | 87 +++++++++ documentation/notifications.md | 92 +++++++++ documentation/repeater-management.md | 186 ++++++++++++++++++ documentation/scanner-and-connection.md | 124 ++++++++++++ documentation/settings.md | 169 ++++++++++++++++ 12 files changed, 1712 insertions(+) create mode 100644 documentation/README.md create mode 100644 documentation/additional-features.md create mode 100644 documentation/ble-protocol.md create mode 100644 documentation/channels.md create mode 100644 documentation/chat-and-messaging.md create mode 100644 documentation/contacts.md create mode 100644 documentation/map-and-location.md create mode 100644 documentation/navigation.md create mode 100644 documentation/notifications.md create mode 100644 documentation/repeater-management.md create mode 100644 documentation/scanner-and-connection.md create mode 100644 documentation/settings.md diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..1367013 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,30 @@ +# MeshCore Open - Feature Documentation + +MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does. + +## Table of Contents + +1. [Scanner & Connection](scanner-and-connection.md) - BLE scanning, USB serial, and TCP connection +2. [Navigation](navigation.md) - App flow, device screen, and quick-switch navigation +3. [Contacts](contacts.md) - Contact management, groups, discovery, and sharing +4. [Chat & Messaging](chat-and-messaging.md) - Direct messages, message status, reactions, and retries +5. [Channels](channels.md) - Broadcast channels, communities, and channel chat +6. [Map & Location](map-and-location.md) - Node map, path tracing, line-of-sight, and offline caching +7. [Settings](settings.md) - Device settings, app settings, radio configuration, and exports +8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences +9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors +10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more +11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture + +## App Overview + +MeshCore Open connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can: + +- **Chat** with other mesh nodes via encrypted direct messages +- **Broadcast** on shared channels (public, hashtag, private, or community-scoped) +- **View nodes on a map** with GPS locations, predicted positions, and path traces +- **Manage repeaters** with CLI access, telemetry, neighbor info, and settings +- **Share contacts** via `meshcore://` URIs and QR codes +- **Configure radio settings** including frequency, power, bandwidth, and spreading factor +- **Cache offline maps** for use without internet connectivity +- **Analyze line-of-sight** between nodes with terrain elevation profiles diff --git a/documentation/additional-features.md b/documentation/additional-features.md new file mode 100644 index 0000000..f7b8319 --- /dev/null +++ b/documentation/additional-features.md @@ -0,0 +1,187 @@ +# Additional Features + +## GIF Picker (Giphy Integration) + +### How to Access +In any chat screen (direct or channel), tap the GIF button in the message input bar. + +### What the User Sees +A bottom sheet with a search field and a grid of GIF thumbnails. + +### Key Interactions +- On open, loads trending GIFs (G-rated, 25 results) +- Type to search and press the keyboard submit button (search triggers on submit, not on each keystroke). Clearing the search field reloads trending GIFs +- On network/API errors, a "Retry" button is shown in-place +- Tap a GIF to select it — the chat input shows an inline preview with an X button to dismiss +- Send the message to transmit the GIF reference (`g:`) +- Recipients see the GIF rendered inline via Giphy CDN +- "Powered by Giphy" attribution is always shown at the bottom of the picker +- The bottom sheet occupies 70% of screen height + +--- + +## Localization / Multi-Language Support + +### How to Access +App Settings → Appearance → Language + +### Supported Languages (15) +English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian + +### How It Works +- All UI strings go through Flutter's ARB localization system +- Language can follow the system locale or be explicitly overridden +- Changes take effect immediately + +--- + +## Discovered Contacts Screen + +### How to Access +From Contacts screen → overflow menu → "Discovered Contacts" + +### What the User Sees +A list of nodes heard passively over the air but not yet added as contacts. Each shows: +- Color-coded avatar (by type) +- Name +- Short public key +- Last-seen time + +### Key Interactions +- Search bar with debounced filtering +- Sort by last seen or name; filter by type +- **Tap**: Import the contact (adds to your contact list) +- **Long-press**: Add Contact, Copy `meshcore://` URI to clipboard, or Delete +- Overflow menu → "Delete All" (with confirmation) +- Already-known contacts and your own node are filtered out + +--- + +## SMAZ Compression + +### What It Is +An optional per-contact and per-channel text compression feature using the SMAZ algorithm (optimized for short English text). + +### How to Enable +- **Per contact**: Chat screen → info button → toggle "SMAZ compression" +- **Per channel**: Long-press channel → Edit → toggle "SMAZ compression" + +### How It Works +- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed +- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data +- Recipients using MeshCore Open will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text +- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages +- Disabled by default + +--- + +## Community QR Scanner + +### How to Access +From Channels screen → "+" FAB → "Scan Community QR" + +### What the User Sees +A live QR scanner view with instruction text overlay. + +### Key Interactions +- Scan a community QR code shared by another member +- On valid scan: confirmation dialog showing community name and ID +- Option to "Add public channel to device" on join +- If already a member: shows an "Already a member" dialog +- Invalid QR: shows an orange error snackbar + +--- + +## Channel Message Path Viewing + +### How to Access +In a channel chat, tap a message bubble (mobile) or use the "Path" action (desktop). + +### What the User Sees +- Summary card: sender, time, repeat count, path type, observed hops +- "Other Observed Paths" section (if multiple paths detected) +- "Repeater Hops" section listing each hop with hex prefix, resolved name, and GPS coordinates + +### Actions +- **Radar icon**: Opens path trace map for live trace +- **Map icon**: Opens a map with hop markers and polyline +- **Path dropdown**: Switch between observed path variants (if multiple) + +--- + +## Debug Logging + +### BLE Debug Log +**Access**: Settings → BLE Debug Log + +Two views: +- **Frames**: Each BLE frame with direction, description, hex preview, timestamp. Long-press to copy hex. +- **Raw Log RX**: Decoded LoRa packets with route type, payload type, path bytes, and summary. + +### App Debug Log +**Access**: Settings → App Debug Log (must be enabled first in App Settings → Debug) + +Structured log entries with level (Info/Warning/Error), tag, message, and timestamp. + +Both logs support copy-all and clear operations. + +--- + +## Chrome Required Screen + +### When It Appears +Automatically shown on web platforms when a non-Chromium browser is detected. + +### What the User Sees +A full-screen informational page explaining that Web Bluetooth requires a Chromium-based browser. No interactive elements — purely informational. + +--- + +## Path History Service + +### What It Does (Background Service) +Maintains an in-memory LRU cache of up to 50 contacts, each with up to 100 route history entries, tracking: +- Hop count and trip time +- Success/failure counts and route weights +- Flood vs. direct discovery + +### Path Scoring +Paths are scored using a weighted formula: reliability (45%), route weight (20%), latency (25%), and freshness (10%). These weights are internal and not user-configurable. Paths whose weight drops to zero or below are automatically deleted. Flood deliveries that receive an ACK give a weight boost (+0.5) to the specific return path. + +Used internally for: +- **Auto route rotation**: Cycles through known paths using configurable weights on retries, with a diversity window to avoid re-using recently tried paths +- **Path selection**: Picks the best-scored path for each retry attempt +- **Flood statistics**: Tracks flood vs. direct discovery ratios + +--- + +## Message Retry Service + +### What It Does (Background Service) +Handles reliable delivery of outgoing direct messages: +1. Assigns a UUID and sends immediately. Only one message per contact can be in-flight at a time (avoids overflowing the firmware's 8-entry ACK table); subsequent messages are queued +2. Listens for ACK frames matched via SHA-256 hash of `[timestamp][attempt][text][sender_pubkey]` +3. On timeout, retries with exponential backoff: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s...) +4. Each retry may use a different path (via path history diversity window) +5. After max retries: marks failed but keeps a **30-second grace window** during which a late ACK can still resolve the message to "delivered". Optionally clears the contact's path +6. Reports RTT and path data for quality learning +7. Maintains an ACK hash history (last 50 entries) to handle duplicate ACKs + +### Configurable Settings (App Settings → Messaging) +- Max retries (2–10, default 5) +- Clear path on max retry (on/off) +- Auto route rotation with weight parameters + +--- + +## Timeout Prediction (ML) + +### What It Does (Background Service) +An ML-based service that predicts expected delivery timeouts: +- Collects delivery observations (path length, message size, time since last RX, delivery time) in a sliding window of up to 100 observations (oldest evicted first) +- Requires **10 minimum observations** before first training. After that, retrains every 5 new observations +- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time) +- Features with zero variance are automatically excluded from training +- Blends per-contact statistics with ML predictions +- Falls back to `3000 + 3000 × pathLength` ms when insufficient data +- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost) diff --git a/documentation/ble-protocol.md b/documentation/ble-protocol.md new file mode 100644 index 0000000..9f4c1d7 --- /dev/null +++ b/documentation/ble-protocol.md @@ -0,0 +1,249 @@ +# BLE Protocol & Data Layer + +This is a technical reference for the communication protocol and data architecture. + +## Transport Layer + +The app supports three transports, all sharing the same command/response protocol: + +| Transport | Method | Implementation | +|---|---|---| +| Bluetooth LE | Nordic UART Service (NUS) GATT | `flutter_blue_plus` | +| USB Serial | Packet-framed serial | `MeshCoreUsbManager` | +| TCP | Packet-framed socket | `MeshCoreTcpConnector` | + +### BLE (Nordic UART Service) + +- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e` +- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e` +- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e` + +Raw `Uint8List` payloads are written directly to the RX characteristic. Writes use "write without response" if supported, falling back to "write with response". + +### USB and TCP Framing + +Both use a lightweight packet framing codec: + +``` +TX (host → device): [0x3C][len_lo][len_hi][payload...] +RX (device → host): [0x3E][len_lo][len_hi][payload...] +``` + +- Frame start: `0x3C` (`<`) for outgoing, `0x3E` (`>`) for incoming +- Length: 2-byte little-endian, payload only +- Max payload: 172 bytes +- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving +- USB: 10ms post-write delay between frames + +## Connection State Machine + +``` +enum MeshCoreConnectionState { + disconnected, + scanning, + connecting, + connected, + disconnecting, +} +``` + +## BLE Connection Lifecycle + +1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]` +2. **Connect** with 15-second timeout +3. **Request MTU** 185 bytes (non-web only) +4. **Discover services** and locate NUS +5. **Enable TX notifications** (up to 3 attempts on native) +6. **Subscribe** to TX characteristic for incoming frames +7. **Initial sync**: device info query, time sync, channel sync + +## Auto-Reconnect (BLE Only) + +On unexpected disconnection, auto-reconnect with exponential backoff: +- Delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s... +- Resets on successful connection +- Disabled for manual disconnects +- Not available for USB or TCP + +## Protocol Constants + +| Constant | Value | Description | +|---|---|---| +| Max frame size | 172 bytes | BLE/USB/TCP payload limit | +| Public key size | 32 bytes | Ed25519 public key | +| Max path size | 64 bytes | Maximum path data | +| Max name size | 32 bytes | Maximum node name | +| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` | +| App protocol version | 3 | Sent in device query | +| Contact frame size | 148 bytes | Fixed-size contact record | + +## Command Codes (App → Device) + +| Code | Name | Description | +|------|------|-------------| +| 1 | CMD_APP_START | Announce app connection | +| 2 | CMD_SEND_TXT_MSG | Send direct text message | +| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send channel text message | +| 4 | CMD_GET_CONTACTS | Request contact list | +| 5 | CMD_GET_DEVICE_TIME | Query device clock | +| 6 | CMD_SET_DEVICE_TIME | Set device clock | +| 7 | CMD_SEND_SELF_ADVERT | Broadcast own advertisement | +| 8 | CMD_SET_ADVERT_NAME | Set node name | +| 9 | CMD_ADD_UPDATE_CONTACT | Add or update a contact | +| 10 | CMD_SYNC_NEXT_MESSAGE | Request next queued message | +| 11 | CMD_SET_RADIO_PARAMS | Set radio parameters | +| 12 | CMD_SET_RADIO_TX_POWER | Set TX power | +| 13 | CMD_RESET_PATH | Reset contact path | +| 14 | CMD_SET_ADVERT_LATLON | Set advertised location | +| 15 | CMD_REMOVE_CONTACT | Remove a contact | +| 16 | CMD_SHARE_CONTACT | Share contact to mesh | +| 17 | CMD_EXPORT_CONTACT | Export contact as bytes | +| 18 | CMD_IMPORT_CONTACT | Import contact from bytes | +| 19 | CMD_REBOOT | Reboot device | +| 20 | CMD_GET_BATT_AND_STORAGE | Query battery and storage | +| 22 | CMD_DEVICE_QUERY | Query device info | +| 26 | CMD_SEND_LOGIN | Login to repeater/room | +| 27 | CMD_SEND_STATUS_REQ | Request repeater status | +| 30 | CMD_GET_CONTACT_BY_KEY | Get contact by public key | +| 31 | CMD_GET_CHANNEL | Get channel definition | +| 32 | CMD_SET_CHANNEL | Set channel name and PSK | +| 36 | CMD_SEND_TRACE_PATH | Request path trace | +| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters | +| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry | +| 40 | CMD_GET_CUSTOM_VAR | Get custom variables | +| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable | +| 50 | CMD_SEND_BINARY_REQ | Send binary request | +| 57 | CMD_SEND_ANON_REQ | Send anonymous request | +| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration | +| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration | + +## Response / Push Codes (Device → App) + +| Code | Name | Description | +|------|------|-------------| +| 0 | RESP_CODE_OK | Generic success | +| 1 | RESP_CODE_ERR | Generic error | +| 2 | RESP_CODE_CONTACTS_START | Contact list begins | +| 3 | RESP_CODE_CONTACT | Single contact data | +| 4 | RESP_CODE_END_OF_CONTACTS | Contact list complete | +| 5 | RESP_CODE_SELF_INFO | Device self-info response | +| 6 | RESP_CODE_SENT | Message transmitted; carries `[1]=is_flood, [2–5]=ack_hash, [6–9]=estimated_timeout_ms` | +| 7 | RESP_CODE_CONTACT_MSG_RECV | Incoming direct message (v2) | +| 8 | RESP_CODE_CHANNEL_MSG_RECV | Incoming channel message (v2) | +| 10 | RESP_CODE_NO_MORE_MESSAGES | No more queued messages | +| 11 | RESP_CODE_EXPORT_CONTACT | Exported contact data | +| 9 | RESP_CODE_CURR_TIME | Current device time | +| 12 | RESP_CODE_BATT_AND_STORAGE | Battery mV (uint16 LE) + storage used/total (uint32 LE each) | +| 13 | RESP_CODE_DEVICE_INFO | Firmware info | +| 16 | RESP_CODE_CONTACT_MSG_RECV_V3 | Incoming direct message (v3) | +| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) | +| 18 | RESP_CODE_CHANNEL_INFO | Channel definition | +| 21 | RESP_CODE_CUSTOM_VARS | Custom variables | +| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags | +| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen | +| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact | +| 0x82 | PUSH_CODE_SEND_CONFIRMED | Delivery ACK from remote; carries ACK hash (4 bytes) + trip time (4 bytes) | +| 0x83 | PUSH_CODE_MSG_WAITING | Offline messages queued | +| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Repeater/room login succeeded | +| 0x86 | PUSH_CODE_LOGIN_FAIL | Repeater/room login failed | +| 0x87 | PUSH_CODE_STATUS_RESPONSE | Repeater status response | +| 0x88 | PUSH_CODE_LOG_RX_DATA | Radio RX data with SNR (int8, units 1/4 dB), RSSI, and raw radio packet | +| 0x89 | PUSH_CODE_TRACE_DATA | Path trace result | +| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered | +| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data | +| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response | + +## Data Models + +### Contact +32-byte public key (primary identity), name, type (chat/repeater/room/sensor), flags, path data, GPS coordinates, last-seen timestamp. Parsed from 148-byte firmware frames with this layout: + +``` +[0] = resp_code +[1–32] = public key (32 bytes) +[33] = type (1=chat, 2=repeater, 3=room, 4=sensor) +[34] = flags (bit 0 = favorite) +[35] = path_length +[36–99] = path (64 bytes) +[100–131] = name (32 bytes, null-padded) +[132–135] = timestamp (uint32 LE) +[136–139] = latitude (int32 LE, × 1e-6 degrees) +[140–143] = longitude (int32 LE, × 1e-6 degrees) +[144–147] = last_modified (uint32 LE) +``` + +### Message (Direct) +Sender key, text, timestamp, outgoing flag, status (pending/sent/delivered/failed), message ID (UUID), retry count, ACK hash, trip time, path data, reactions. + +### Channel Message +Sender name, text, timestamp, status (pending/sent/failed), repeater hops, path variants, channel index, reactions, reply threading fields. + +### Channel +Index (0–7), name, 16-byte PSK, unread count. PSK derivation methods for hashtag (SHA-256) and community (HMAC-SHA256) channels. + +### Community +UUID, name, 32-byte secret, hashtag channel list. Shared via QR code. + +## Persistence + +All data is stored via `SharedPreferences` (JSON-serialized). No SQLite or other database. + +| Data | Storage Key Pattern | Scope | +|---|---|---| +| Contacts | `contacts` | Per device identity | +| Messages | `messages_` | Per device + contact | +| Channel Messages | `channel_messages_` | Per device + channel | +| Channels | `channels` | Per device identity | +| Channel Order | `channel_order_` | Per device identity | +| Contact Groups | `contact_groups` | Per device identity | +| Communities | `communities_v1` | Per device identity | +| Unread Counts | `contact_unread_count` | Per device identity | +| Discovered Contacts | `discovered_contacts` | Global | +| App Settings | `app_settings` | Global | +| Path History | `path_history_` | Per contact | + +## Auto-Add Configuration Bitmask + +Used by `CMD_SET_AUTO_ADD_CONFIG` (58) and `RESP_CODE_AUTO_ADD_CONFIG` (25): + +| Bit | Flag | Description | +|-----|------|-------------| +| 0 | 0x01 | Overwrite oldest contact when list is full | +| 1 | 0x02 | Auto-add chat users | +| 2 | 0x04 | Auto-add repeaters | +| 3 | 0x08 | Auto-add room servers | +| 4 | 0x10 | Auto-add sensors | + +## Radio Packet Payload Types + +Seen inside `PUSH_CODE_LOG_RX_DATA` raw packets: + +| Code | Type | +|------|------| +| 0x00 | REQ (request) | +| 0x01 | RESPONSE | +| 0x02 | TXTMSG (text message) | +| 0x03 | ACK | +| 0x04 | ADVERT | +| 0x05 | GRPTXT (group/channel text) | +| 0x06 | GRPDATA (group data) | +| 0x07 | ANONREQ (anonymous request) | +| 0x08 | PATH | +| 0x09 | TRACE | +| 0x0A | MULTIPART | +| 0x0B | CONTROL | +| 0x0F | RAW_CUSTOM | + +## State Management + +Uses Flutter `Provider` with `ChangeNotifier`. The central state holder is `MeshCoreConnector`, which owns all in-memory collections and fires debounced (50ms) `notifyListeners()` to update the UI. In-memory conversations are windowed to 200 messages per contact; older messages remain on disk and are loaded on demand. + +### Data Flow + +1. Raw frames arrive over BLE/USB/TCP +2. First byte is parsed as response/push code +3. Appropriate model factory (`fromFrame()`) parses the data +4. In-memory collections are updated +5. Storage stores are persisted (async) +6. `notifyListeners()` triggers UI rebuilds +7. Screens read current state via getters diff --git a/documentation/channels.md b/documentation/channels.md new file mode 100644 index 0000000..21fb52e --- /dev/null +++ b/documentation/channels.md @@ -0,0 +1,164 @@ +# Channels + +## Overview + +Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh. + +Up to 8 channels (indices 0–7) can be active simultaneously on one device. + +## How to Access + +QuickSwitchBar tab 1 (middle) from any main screen. + +## Channel Types + +| Type | Icon | Color | Description | +|---|---|---|---| +| Public | Globe | Green | Fixed well-known PSK; any device can join | +| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention | +| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key | +| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret | + +## Channels List Screen + +### What the User Sees + +- **Search bar** with live text filtering (300ms debounce) +- **Sort/filter button** +- **Scrollable list of channel cards**, each showing: + - Type icon with color coding (purple badge overlay for community channels) + - Channel name (or "Channel N" if unnamed) + - Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}" + - Unread badge (if messages are unread) + - Drag handle (when manual sort is active) +- **"+" FAB** to add a new channel +- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings + +If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown. + +Pull-to-refresh (swipe down) forces a re-fetch of channels from the device firmware. + +### Sorting Options + +- **Manual** (default): Drag-and-drop reordering, persisted (drag handles are hidden when a search query is active) +- **A–Z**: Alphabetical +- **Latest messages**: Most recent first +- **Unread**: Most unread first + +## Adding a Channel + +Tap the "+" FAB to open a dialog with six options: + +1. **Create Private Channel** — Enter a name (max 31 characters); a random PSK is generated +2. **Join Private Channel** — Enter a name and a 32-hex PSK (non-hex characters like spaces and dashes are silently stripped, so pasted keys with formatting are accepted) +3. **Join Public Channel** — One tap; uses the well-known public PSK (only shown if no public channel exists) +4. **Join Hashtag Channel** — Enter a hashtag name; PSK is derived from the name. If communities exist, choose between regular hashtag (SHA-256) or community hashtag (HMAC) +5. **Scan Community QR** — Opens QR scanner to join a community +6. **Create Community** — Enter a name; generates a random 32-byte secret; optionally adds a community public channel; shows QR code for sharing + +## Channel Actions (Long-Press / Right-Click) + +| Action | Description | +|---|---| +| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) | +| Mute / Unmute | Toggle push notification suppression for this channel | +| Delete | Remove the channel from the device (confirmation required) | + +## Channel Chat + +Tap a channel card to open the channel chat screen. + +### App Bar + +- Type icon (public/private/hashtag) +- Channel name +- Subtitle: "{type} - {N} unread" + +### Message Display + +- Reverse-scrolling list (newest at bottom) +- **Incoming messages**: Colored avatar with sender's initial (or first emoji if name starts with one; color is deterministic from sender name hash), sender name in primary color, message bubble +- **Outgoing messages**: Primary container color bubble with a small status icon: pending (clock), sent (checkmark), or failed (red error circle) +- Automatic older-message loading on scroll-to-top +- Jump-to-bottom button when scrolled up +- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset text size +- **Message tracing mode** (when enabled in App Settings): Each bubble additionally shows path prefix bytes (`via XX,YY,...`), a timestamp, and a repeat count icon + +### Message Types in Chat + +- **Plain text** with linkified URLs +- **GIFs** (`g:{gifId}`) rendered inline via Giphy CDN +- **Location pins** (`m:{lat},{lon}|{label}|`) shown as tappable location cards +- **Reactions** displayed as emoji pills below target messages + +### Replies (Channel Chat Only) + +- **Mobile**: Swipe an **incoming** message left to trigger reply (with haptic feedback). You cannot swipe your own outgoing messages. Swipe reply is not available on desktop. +- **All platforms**: Long-press → "Reply" +- Reply banner appears above the input bar with the quoted message (tap X to cancel) +- Sent replies are prefixed `@[{senderName}] {text}` +- Received replies show a bordered quote block inside the bubble; tapping scrolls to the original. Reply previews render GIF thumbnails and location pin icons, not just text. + +### Message Path Viewing + +- **Mobile**: Tap a message bubble to view its routing path +- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop) +- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md)) + +### Context Actions (Long-Press / Right-Click) + +| Action | Availability | Description | +|---|---|---| +| Reply | All messages | Triggers reply mode | +| Path | Desktop only | Opens message path view | +| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) | +| Copy | All messages | Copies text to clipboard | +| Delete | All messages | Removes locally (not from mesh) | + +### Message Path Viewing + +Tap a message bubble to open the Channel Message Path Screen, which shows: +- Each hop in the path as a visual chain +- Known contacts identified by name at each hop +- Observed vs. declared hop counts +- Alternative path variants (if received via multiple paths) +- Map view buttons for geographic path visualization + +## Communities + +Communities are a layer above channels that provide a private namespace. + +### What is a Community? + +A community has a name and a 32-byte random secret. Channel PSKs are derived from this secret: +- **Public channel**: `HMAC-SHA256(secret, "channel:v1:__public__")[:16]` +- **Hashtag channel**: `HMAC-SHA256(secret, "channel:v1:{hashtag}")[:16]` + +Outsiders who don't know the secret cannot discover or join community channels. + +### Sharing a Community + +Communities are shared via QR codes containing a JSON payload: +```json +{"v": 1, "type": "meshcore_community", "name": "...", "k": ""} +``` + +### Managing Communities + +From the channels screen overflow menu → "Manage Communities". Opens a draggable scrollable sheet (resizable 30–90% of screen height): + +- Each community shows its name and a short community ID (first 8 hex characters) +- **Tap a community** to directly show its QR code for sharing +- **Popup menu** per community: + - **Show QR** — displays the QR code for sharing with new members + - **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed) + +## How Channels Differ from Direct Messages + +| Aspect | Channels | Direct Messages | +|---|---|---| +| Addressing | Broadcast to all nodes with matching PSK | Point-to-point to a specific contact | +| Encryption | Shared PSK (symmetric) | Contact's public key (asymmetric) | +| Sender identity | Plain text prefix in payload | Verified via public key | +| Replies | Supported (swipe or long-press) | Not supported | +| Retry mechanism | No automatic retry | Exponential backoff with path rotation | diff --git a/documentation/chat-and-messaging.md b/documentation/chat-and-messaging.md new file mode 100644 index 0000000..22030d5 --- /dev/null +++ b/documentation/chat-and-messaging.md @@ -0,0 +1,120 @@ +# Chat & Messaging + +## Overview + +The app supports two chat modes: +- **Direct messages**: Encrypted point-to-point messages to individual contacts +- **Channel messages**: Broadcast messages to shared channels (see [Channels](channels.md)) + +This page covers direct messaging. For channel chat, see the Channels documentation. + +## How to Access + +From the Contacts screen, tap any Chat-type contact to open the ChatScreen. + +## Chat Screen Layout + +### App Bar + +- **Title**: Contact name +- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details. +- **Action buttons**: + - **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing + - **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries. + - **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle + +### Message List + +- Scrollable list with newest messages at the bottom +- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background +- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name) +- Bubble width capped at 65% of screen width +- Hyperlinks rendered as tappable green underlined text +- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset +- **Jump to bottom**: Floating button appears when scrolled away from the bottom +- **Lazy loading**: Scrolling to top loads older messages from storage + +### Input Bar + +- **GIF button** (left): Opens GIF picker bottom sheet +- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time +- **Send button** (right): Submits the message +- On desktop: Enter/Numpad Enter also submits +- When a GIF is selected, the text field shows an inline GIF preview with a dismiss button + +## Message Types + +| Type | Wire Format | Display | +|---|---|---| +| Plain text | Raw UTF-8 string | Inline text with link detection | +| GIF | `g:` | Inline GIF image from Giphy CDN | +| Location pin | `m:,\|