Compare commits

...

16 Commits

Author SHA1 Message Date
zjs81 fffcff3b74 fix: cancel persist timer on dispose to prevent post-dispose writes 2026-03-14 17:39:01 -07:00
zjs81 b336aedbc5 fix: address PR #296 code review feedback
- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
2026-03-14 17:32:08 -07:00
zjs81 2ee2358ecc feat: add ML-based adaptive timeout prediction using LinearRegressor
Train a linear regression model on actual message delivery times to
predict tighter timeouts, replacing worst-case physics estimates.
Features: path length, message bytes, seconds since last RX, flood mode.
Global model with per-contact blending after 10+ observations per contact.
Falls back to existing physics formula when model has insufficient data.
2026-03-14 16:56:11 -07:00
zjs81 8b280b37be Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
2026-03-14 09:55:02 -07:00
zjs81 fa4da979af feat: enhance location update feedback and improve message retry error handling 2026-03-14 09:54:50 -07:00
zjs81 91608ff09e feat: improve message matching logic and update notification IDs for advertisements 2026-03-14 09:44:37 -07:00
zjs81 71f59d23df feat: add set-as-my-location from map long-press, connector and UI improvements
Add "Set as my location" option to the map long-press bottom sheet,
allowing users to set their device position directly from the map.
Includes connector, chat, contacts, and message retry service improvements.
2026-03-14 09:33:37 -07:00
zjs81 e90742be25 Merge pull request #272 from just-stuff-tm/tcp
feat: Add TCP connection support and UI integration
2026-03-13 11:04:11 -07:00
Zach db935a7454 refactor(tcp): promote MeshCoreTcpConnector, fix translations, harden UI
- 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.
2026-03-13 10:59:09 -07:00
Winston Lowe 1ad5db27ca Merge branch 'main' into tcp 2026-03-12 23:22:30 -07:00
Winston Lowe 81758adc61 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>
2026-03-12 23:08:46 -07:00
just-stuff-tm 2f770bbd53 fix(tcp): reset state on aborted pre-handshake connect 2026-03-10 21:38:35 -04:00
just-stuff-tm 9db79e9d40 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
2026-03-10 20:06:05 -04:00
just-stuff-tm 1913a5aa11 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
2026-03-10 19:27:39 -04:00
just-stuff-tm 929c1c3d28 fix(tcp): cancel pending connects on disconnect and propagate remote close 2026-03-09 20:39:17 -04:00
just-stuff-tm 7a2bb20bf7 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.
2026-03-07 20:07:19 -05:00
80 changed files with 4189 additions and 485 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+345 -63
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart' as crypto; import 'package:crypto/crypto.dart' as crypto;
import 'package:meshcore_open/models/discovery_contact.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -20,8 +19,10 @@ import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/background_service.dart'; import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart'; import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart';
import '../storage/channel_message_store.dart'; import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart'; import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart'; import '../storage/channel_settings_store.dart';
@@ -86,7 +87,7 @@ enum MeshCoreConnectionState {
disconnecting, disconnecting,
} }
enum MeshCoreTransportType { bluetooth, usb } enum MeshCoreTransportType { bluetooth, usb, tcp }
class RepeaterBatterySnapshot { class RepeaterBatterySnapshot {
final int millivolts; final int millivolts;
@@ -116,11 +117,12 @@ class MeshCoreConnector extends ChangeNotifier {
bool _manualDisconnect = false; bool _manualDisconnect = false;
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
StreamSubscription<Uint8List>? _usbFrameSubscription; StreamSubscription<Uint8List>? _usbFrameSubscription;
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = []; final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = []; final List<Contact> _contacts = [];
final List<DiscoveryContact> _discoveredContacts = []; final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = []; final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {}; final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {}; final Map<int, List<ChannelMessage>> _channelMessages = {};
@@ -165,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = 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 _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false; bool _hasReceivedDeviceInfo = false;
@@ -198,6 +204,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Serializes path operations (setContactPath/clearContactPath) to prevent
// interleaved async calls from leaving in-memory state inconsistent with device.
Future<void> _pathOpLock = Future.value();
Map<String, String>? _currentCustomVars; Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern) // Channel syncing state (sequential pattern)
@@ -255,6 +264,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isUsbTransportConnected => bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected && _state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb; _activeTransport == MeshCoreTransportType.usb;
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
bool get isTcpTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.tcp;
String get deviceDisplayName { String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) { if (_selfName != null && _selfName!.isNotEmpty) {
@@ -281,7 +294,7 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
List<DiscoveryContact> get discoveredContacts { List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts); return List.unmodifiable(_discoveredContacts);
} }
@@ -553,6 +566,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
); );
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -571,6 +588,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels, _channels.isNotEmpty ? _channels : _cachedChannels,
), ),
); );
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -652,6 +673,7 @@ class MeshCoreConnector extends ChangeNotifier {
BleDebugLogService? bleDebugLogService, BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService, AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
@@ -659,12 +681,13 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
_usbManager.setDebugLogService(_appDebugLogService); _usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService);
// Initialize notification service // Initialize notification service
_notificationService.initialize(); _notificationService.initialize();
_loadChannelOrder(); _loadChannelOrder();
_loadDiscoveredContactCache();
// Initialize retry service callbacks // Initialize retry service callbacks
_retryService?.initialize( _retryService?.initialize(
@@ -673,13 +696,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback:
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), (pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey, getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText, prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService, debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, 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,
);
},
); );
} }
@@ -966,6 +1004,142 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
Future<void> 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 {
Future<void> 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 (_tcpConnector.isConnected) {
await _tcpConnector.disconnect();
}
}
await _tcpConnector.cancelFrameSubscription();
await _tcpConnector.connect(host: host, port: port);
final isTcpConnectCancelled =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelled) {
await handleTcpConnectAbort(
message:
'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
notifyListeners();
await Future<void>.delayed(const Duration(milliseconds: 200));
final isTcpConnectCancelledAfterDelay =
_activeTransport != MeshCoreTransportType.tcp ||
_state != MeshCoreConnectionState.connecting ||
!_tcpConnector.isConnected;
if (isTcpConnectCancelledAfterDelay) {
await handleTcpConnectAbort(
message:
'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}',
);
return;
}
_tcpConnector.listenFrames(
onFrame: _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');
final tcpConnectCancelledBeforeHandshake =
shouldIgnoreLateTcpConnectError(
manualDisconnect: _manualDisconnect,
state: _state,
activeTransport: _activeTransport,
tcpManagerConnected: _tcpConnector.isConnected,
);
if (tcpConnectCancelledBeforeHandshake) {
_appDebugLogService?.info(
'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport',
tag: 'TCP',
);
return;
}
await disconnect(manual: false);
rethrow;
}
}
@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);
}
@visibleForTesting
static bool shouldResetStateAfterTcpConnectAbort({
required MeshCoreConnectionState state,
required MeshCoreTransportType activeTransport,
}) {
return state == MeshCoreConnectionState.connecting &&
activeTransport == MeshCoreTransportType.tcp;
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async { Future<void> connect(BluetoothDevice device, {String? displayName}) async {
if (_state == MeshCoreConnectionState.connecting || if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) { _state == MeshCoreConnectionState.connected) {
@@ -1231,6 +1405,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool get _shouldGateInitialChannelSync => bool get _shouldGateInitialChannelSync =>
_activeTransport == MeshCoreTransportType.usb || _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp ||
(_activeTransport == MeshCoreTransportType.bluetooth && (_activeTransport == MeshCoreTransportType.bluetooth &&
PlatformInfo.isWeb); PlatformInfo.isWeb);
@@ -1277,9 +1452,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> disconnect({bool manual = true}) async { Future<void> disconnect({bool manual = true}) async {
if (_state == MeshCoreConnectionState.disconnecting) return; if (_state == MeshCoreConnectionState.disconnecting) return;
final transportAtDisconnect = _activeTransport; final transportAtDisconnect = _activeTransport;
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb final transportLabel = switch (transportAtDisconnect) {
? 'USB' MeshCoreTransportType.bluetooth => 'BLE',
: 'BLE'; MeshCoreTransportType.usb => 'USB',
MeshCoreTransportType.tcp => 'TCP',
};
_appDebugLogService?.info( _appDebugLogService?.info(
'Starting disconnect transport=$transportLabel manual=$manual', 'Starting disconnect transport=$transportLabel manual=$manual',
@@ -1299,6 +1476,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel(); await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null; _usbFrameSubscription = null;
await _usbManager.disconnect(); await _usbManager.disconnect();
await _tcpConnector.disconnect();
await _notifySubscription?.cancel(); await _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
@@ -1380,6 +1558,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) { if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data); await _usbManager.write(data);
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else { } else {
if (_rxCharacteristic == null) { if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available"); throw Exception("MeshCore RX characteristic not available");
@@ -1593,18 +1773,33 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath, Uint8List customPath,
int pathLen, int pathLen,
) async { ) async {
if (!isConnected) return; // Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame( await sendFrame(
buildUpdateContactPathFrame( buildUpdateContactPathFrame(
contact.publicKey, contact.publicKey,
customPath, customPath,
pathLen, pathLen,
type: contact.type, type: contact.type,
flags: contact.flags, flags: contact.flags,
name: contact.name, name: contact.name,
), ),
); );
// USB writes return instantly (no BLE flow control), so give the firmware
// time to persist the path change before subsequent commands.
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
completer.complete();
}
} }
Future<void> setContactFavorite(Contact contact, bool isFavorite) async { Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
@@ -1904,7 +2099,11 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> removeContact(Contact contact) async { Future<void> removeContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_handleDiscovery(contact, Uint8List(0), noNotify: true); _handleDiscovery(
contact,
contact.rawPacket ?? Uint8List(0),
noNotify: true,
);
await sendFrame(buildRemoveContactFrame(contact.publicKey)); await sendFrame(buildRemoveContactFrame(contact.publicKey));
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex); _contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
@@ -1920,7 +2119,20 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> removeDiscoveredContact(DiscoveryContact contact) async { Future<void> 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<void> removeDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
_discoveredContacts.removeWhere( _discoveredContacts.removeWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
@@ -1929,7 +2141,7 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> importDiscoveredContact(DiscoveryContact contact) async { Future<void> importDiscoveredContact(Contact contact) async {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -1938,11 +2150,23 @@ class MeshCoreConnector extends ChangeNotifier {
contact.path, contact.path,
contact.pathLength, contact.pathLength,
type: contact.type, type: contact.type,
flags: 0, flags: contact.flags,
name: contact.name, 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( _handleContactAdvert(
Contact( Contact(
publicKey: contact.publicKey, publicKey: contact.publicKey,
@@ -1953,29 +2177,41 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
flags: contact.flags,
), ),
); );
notifyListeners(); notifyListeners();
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
if (!isConnected) return; // Serialize path operations to prevent interleaved async calls.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey)); await sendFrame(buildResetPathFrame(contact.publicKey));
final existingIndex = _contacts.indexWhere( if (_activeTransport == MeshCoreTransportType.usb) {
(c) => c.publicKeyHex == contact.publicKeyHex, await Future<void>.delayed(const Duration(milliseconds: 100));
); }
if (existingIndex >= 0) { final existingIndex = _contacts.indexWhere(
final existing = _contacts[existingIndex]; (c) => c.publicKeyHex == contact.publicKeyHex,
// Use copyWith to preserve pathOverride and pathOverrideBytes
_contacts[existingIndex] = existing.copyWith(
pathLength: -1,
path: Uint8List(0),
); );
notifyListeners(); if (existingIndex >= 0) {
unawaited(_persistContacts()); 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( void updateContactInMemory(
@@ -2284,6 +2520,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame); _receivedFramesController.add(frame);
@@ -2311,6 +2548,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
@@ -2324,6 +2564,7 @@ class MeshCoreConnector extends ChangeNotifier {
debugPrint('Got END_OF_CONTACTS'); debugPrint('Got END_OF_CONTACTS');
_isLoadingContacts = false; _isLoadingContacts = false;
_preserveContactsOnRefresh = false; _preserveContactsOnRefresh = false;
unawaited(updateKnownDiscovered());
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
@@ -2339,7 +2580,8 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (_pendingDeferredChannelSyncAfterContacts && if (_pendingDeferredChannelSyncAfterContacts &&
(_activeTransport == MeshCoreTransportType.bluetooth || (_activeTransport == MeshCoreTransportType.bluetooth ||
_activeTransport == MeshCoreTransportType.usb)) { _activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp)) {
_pendingDeferredChannelSyncAfterContacts = false; _pendingDeferredChannelSyncAfterContacts = false;
_pendingInitialChannelSync = false; _pendingInitialChannelSync = false;
unawaited(getChannels()); unawaited(getChannels());
@@ -2510,6 +2752,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Load persisted channel messages // Load persisted channel messages
loadAllChannelMessages(); loadAllChannelMessages();
loadUnreadState(); loadUnreadState();
_loadDiscoveredContactCache();
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
@@ -2527,14 +2770,16 @@ class MeshCoreConnector extends ChangeNotifier {
if (PlatformInfo.isWeb && if (PlatformInfo.isWeb &&
_activeTransport == MeshCoreTransportType.bluetooth) { _activeTransport == MeshCoreTransportType.bluetooth) {
_pendingInitialContactsSync = true; _pendingInitialContactsSync = true;
} else if (_activeTransport == MeshCoreTransportType.usb) { } else if (_activeTransport == MeshCoreTransportType.usb ||
_activeTransport == MeshCoreTransportType.tcp) {
_pendingDeferredChannelSyncAfterContacts = true; _pendingDeferredChannelSyncAfterContacts = true;
getContacts(); getContacts();
} else { } else {
getContacts(); getContacts();
} }
if (_shouldGateInitialChannelSync && if (_shouldGateInitialChannelSync &&
_activeTransport != MeshCoreTransportType.usb) { _activeTransport != MeshCoreTransportType.usb &&
_activeTransport != MeshCoreTransportType.tcp) {
_maybeStartInitialChannelSync(); _maybeStartInitialChannelSync();
} }
} }
@@ -2652,38 +2897,68 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Calculate timeout for a message based on radio settings and path length /// Estimate single-packet airtime in ms from radio settings, or a fallback.
/// Returns timeout in milliseconds, considering number of hops int _estimateAirtimeMs(int messageBytes) {
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
// If we have radio settings, use them for accurate calculation
if (_currentFreqHz != null && if (_currentFreqHz != null &&
_currentBwHz != null && _currentBwHz != null &&
_currentSf != null && _currentSf != null &&
_currentCr != null) { _currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4; final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout( return calculateLoRaAirtime(
freqHz: _currentFreqHz!, payloadBytes: messageBytes,
bwHz: _currentBwHz!, spreadingFactor: _currentSf!,
sf: _currentSf!, bandwidthHz: _currentBwHz!,
cr: cr, codingRate: cr,
pathLength: pathLength, lowDataRateOptimize: _currentSf! >= 11,
messageBytes: messageBytes,
); );
} }
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
// Fallback: Conservative estimates based on typical settings /// Physics-based worst-case timeout (ceiling).
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes int _physicsMaxTimeout(int pathLength, int airtime) {
const estimatedAirtime = 50;
if (pathLength < 0) { if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime return 500 + (16 * airtime);
return 500 + (16 * estimatedAirtime);
} else { } else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) return 500 + ((airtime * 6 + 250) * (pathLength + 1));
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
} }
} }
/// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({
required int pathLength,
int messageBytes = 100,
String? contactKey,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx,
);
if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
}
return physicsMax;
}
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
@@ -4302,6 +4577,7 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryPollTimer?.cancel(); _batteryPollTimer?.cancel();
_receivedFramesController.close(); _receivedFramesController.close();
_usbManager.dispose(); _usbManager.dispose();
_tcpConnector.dispose();
// Flush pending unread writes before disposal // Flush pending unread writes before disposal
_unreadStore.flush(); _unreadStore.flush();
@@ -4406,7 +4682,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
importDiscoveredContact( importDiscoveredContact(
DiscoveryContact( Contact(
rawPacket: frame, rawPacket: frame,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
@@ -4477,6 +4753,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (isNewContact) { if (isNewContact) {
final newContact = Contact( final newContact = Contact(
rawPacket: rawPacket,
publicKey: publicKey, publicKey: publicKey,
name: name, name: name,
type: type, type: type,
@@ -4622,13 +4899,15 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
flags: 0,
isActive: false,
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
return; return;
} }
final disContact = DiscoveryContact( final disContact = Contact(
rawPacket: rawPacket, rawPacket: rawPacket,
publicKey: contact.publicKey, publicKey: contact.publicKey,
name: contact.name, name: contact.name,
@@ -4638,6 +4917,9 @@ class MeshCoreConnector extends ChangeNotifier {
latitude: contact.latitude, latitude: contact.latitude,
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: false,
flags: 0,
); );
_discoveredContacts.add(disContact); _discoveredContacts.add(disContact);
+70
View File
@@ -0,0 +1,70 @@
import 'dart:async';
import 'dart:typed_data';
import '../services/app_debug_log_service.dart';
import '../services/tcp_transport_service.dart';
/// 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<Uint8List>? _frameSubscription;
// --- Getters ---
String? get activeEndpoint => _service.activeEndpoint;
bool get isConnected => _service.isConnected;
// --- Configuration ---
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
// --- Connection lifecycle ---
Future<void> connect({required String host, required int port}) async {
_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<Uint8List> 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<void> cancelFrameSubscription() async {
await _frameSubscription?.cancel();
_frameSubscription = null;
}
Future<void> disconnect() async {
if (!_service.isConnected && _frameSubscription == null) return;
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
await _frameSubscription?.cancel();
_frameSubscription = null;
await _service.disconnect();
}
Future<void> write(Uint8List data) => _service.write(data);
void dispose() {
_frameSubscription?.cancel();
_service.dispose();
}
}
@@ -53,6 +53,9 @@ class MeshCoreUsbManager {
} }
Future<void> disconnect() async { Future<void> disconnect() async {
if (!_service.isConnected && _activePortKey == null) {
return;
}
_debugLog?.info('UsbManager.disconnect', tag: 'USB'); _debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect(); await _service.disconnect();
_activePortKey = null; _activePortKey = null;
+40 -13
View File
@@ -148,6 +148,19 @@ class BufferWriter {
void writeHex(String hex) { void writeHex(String hex) {
writeBytes(hex2Uint8List(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) { Uint8List hex2Uint8List(String hex) {
@@ -676,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
} }
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path // 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 buildUpdateContactPathFrame(
Uint8List pubKey, Uint8List pubKey,
Uint8List customPath, Uint8List path,
int pathLen, { int pathLen, {
int type = 1, // ADV_TYPE_CHAT int type = 1, // ADV_TYPE_CHAT
int flags = 0, int flags = 0,
String name = '', String name = '',
double? lat,
double? lon,
DateTime? lastModified,
}) { }) {
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdAddUpdateContact); writer.writeByte(cmdAddUpdateContact);
@@ -692,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
writer.writeByte(flags); writer.writeByte(flags);
writer.writeByte(pathLen); writer.writeByte(pathLen);
// Path data (64 bytes, zero-padded) writer.writeBytesPadded(path, maxPathSize);
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);
// Name (32 bytes, null-padded) // Name (32 bytes, null-padded)
writer.writeCString(name, maxNameSize); writer.writeCString(name, maxNameSize);
@@ -711,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writer.writeUInt32LE(timestamp); 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(); return writer.toBytes();
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbConnectionFailed": "Неуспешно свързване през USB: {error}",
"usbStatus_notConnected": "Изберете USB устройство", "usbStatus_notConnected": "Изберете USB устройство",
"usbStatus_searching": "Търсене на 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_connectingTo": "Свързване към {endpoint}...",
"tcpErrorHostRequired": "Необходим е IP адрес.",
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение"
} }
+30 -1
View File
@@ -1887,5 +1887,34 @@
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
"usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbStatus_connecting": "Verbindung zum USB-Gerät...",
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", "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": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.",
"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}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
} }
+30 -1
View File
@@ -49,6 +49,33 @@
"scanner_title": "MeshCore Open", "scanner_title": "MeshCore Open",
"connectionChoiceUsbLabel": "USB", "connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth", "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_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", "usbScreenTitle": "Connect over USB",
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
"usbScreenStatus": "Select a USB device", "usbScreenStatus": "Select a USB device",
@@ -780,6 +807,7 @@
"map_source": "Source", "map_source": "Source",
"map_flags": "Flags", "map_flags": "Flags",
"map_shareMarkerHere": "Share marker here", "map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label", "map_pinLabel": "Pin label",
"map_label": "Label", "map_label": "Label",
"map_pointOfInterest": "Point of interest", "map_pointOfInterest": "Point of interest",
@@ -807,6 +835,7 @@
"map_markers": "Markers", "map_markers": "Markers",
"map_showSharedMarkers": "Show shared markers", "map_showSharedMarkers": "Show shared markers",
"map_showGuessedLocations": "Show guessed node locations", "map_showGuessedLocations": "Show guessed node locations",
"map_showDiscoveryContacts": "Show Discovery Contacts",
"map_guessedLocation": "Guessed location", "map_guessedLocation": "Guessed location",
"map_lastSeenTime": "Last Seen Time", "map_lastSeenTime": "Last Seen Time",
"map_sharedPin": "Shared pin", "map_sharedPin": "Shared pin",
@@ -1898,4 +1927,4 @@
"discoveredContacts_deleteContact": "Delete Discovered Contact", "discoveredContacts_deleteContact": "Delete Discovered Contact",
"discoveredContacts_deleteContactAll": "Delete All Discovered Contacts", "discoveredContacts_deleteContactAll": "Delete All Discovered Contacts",
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?" "discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?"
} }
+30 -1
View File
@@ -1887,5 +1887,34 @@
"usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_searching": "Buscando dispositivos USB...",
"usbStatus_notConnected": "Seleccione un dispositivo USB", "usbStatus_notConnected": "Seleccione un dispositivo USB",
"usbConnectionFailed": "Error al conectar mediante USB: {error}", "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_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}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbConnectionFailed": "Échec de la connexion USB : {error}", "usbConnectionFailed": "Échec de la connexion USB : {error}",
"usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_connecting": "Connexion au périphérique USB...",
"usbStatus_searching": "Recherche de périphériques 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_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}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbConnectionFailed": "Errore nella connessione USB: {error}", "usbConnectionFailed": "Errore nella connessione USB: {error}",
"usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_notConnected": "Seleziona un dispositivo USB",
"usbStatus_connecting": "Connessione al 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": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
"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}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione"
} }
+90
View File
@@ -334,6 +334,84 @@ abstract class AppLocalizations {
/// **'Bluetooth'** /// **'Bluetooth'**
String get connectionChoiceBluetoothLabel; 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_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. /// No description provided for @usbScreenTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2668,6 +2746,12 @@ abstract class AppLocalizations {
/// **'Share marker here'** /// **'Share marker here'**
String get map_shareMarkerHere; 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. /// No description provided for @map_pinLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2788,6 +2872,12 @@ abstract class AppLocalizations {
/// **'Show guessed node locations'** /// **'Show guessed node locations'**
String get map_showGuessedLocations; 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. /// No description provided for @map_guessedLocation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Свържете се чрез USB'; String get usbScreenTitle => 'Свържете се чрез USB';
@@ -1467,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Споделете маркер тук'; String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override @override
String get map_pinLabel => 'Етикетиране на пин'; String get map_pinLabel => 'Етикетиране на пин';
@@ -1531,6 +1578,9 @@ class AppLocalizationsBg extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Покажете местоположенията на предположените възли.'; 'Покажете местоположенията на предположените възли.';
@override
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
@override @override
String get map_guessedLocation => 'Предполагано местоположение'; String get map_guessedLocation => 'Предполагано местоположение';
+52
View File
@@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected =>
'Geben Sie den Endpunkt ein und verbinden Sie sich.';
@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 @override
String get usbScreenTitle => 'Verbinden über USB'; String get usbScreenTitle => 'Verbinden über USB';
@@ -1467,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.'; String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override @override
String get map_pinLabel => 'Pin Name'; String get map_pinLabel => 'Pin Name';
@@ -1531,6 +1580,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zeige die vermuteten Knotenpositionen'; 'Zeige die vermuteten Knotenpositionen';
@override
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
@override @override
String get map_guessedLocation => 'Geschätzter Ort'; String get map_guessedLocation => 'Geschätzter Ort';
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Connect over USB'; String get usbScreenTitle => 'Connect over USB';
@@ -1443,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Share marker here'; String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override @override
String get map_pinLabel => 'Pin label'; String get map_pinLabel => 'Pin label';
@@ -1506,6 +1553,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Show guessed node locations'; String get map_showGuessedLocations => 'Show guessed node locations';
@override
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
@override @override
String get map_guessedLocation => 'Guessed location'; String get map_guessedLocation => 'Guessed location';
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Conecte mediante USB'; String get usbScreenTitle => 'Conecte mediante USB';
@@ -1465,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartir marcador aquí'; String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override @override
String get map_pinLabel => 'Etiqueta de marcador'; String get map_pinLabel => 'Etiqueta de marcador';
@@ -1529,6 +1576,9 @@ class AppLocalizationsEs extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar las ubicaciones estimadas de los nodos.'; 'Mostrar las ubicaciones estimadas de los nodos.';
@override
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
@override @override
String get map_guessedLocation => 'Ubicación estimada'; String get map_guessedLocation => 'Ubicación estimada';
+52
View File
@@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Connectez via USB'; String get usbScreenTitle => 'Connectez via USB';
@@ -1472,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Partager le marqueur ici'; String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override @override
String get map_pinLabel => 'Étiquete de repin'; String get map_pinLabel => 'Étiquete de repin';
@@ -1536,6 +1585,9 @@ class AppLocalizationsFr extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Afficher les emplacements des nœuds estimés'; 'Afficher les emplacements des nœuds estimés';
@override
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
@override @override
String get map_guessedLocation => 'Lieu deviné'; String get map_guessedLocation => 'Lieu deviné';
+51
View File
@@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.';
@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 @override
String get usbScreenTitle => 'Connessione tramite USB'; String get usbScreenTitle => 'Connessione tramite USB';
@@ -1465,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Condividi marcatore qui'; String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override @override
String get map_pinLabel => 'Etichetta PIN'; String get map_pinLabel => 'Etichetta PIN';
@@ -1528,6 +1576,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi'; String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
@override
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
@override @override
String get map_guessedLocation => 'Località indovinata'; String get map_guessedLocation => 'Località indovinata';
+51
View File
@@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Poort';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind';
@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 @override
String get usbScreenTitle => 'Verbind via USB'; String get usbScreenTitle => 'Verbind via USB';
@@ -1457,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Deel marker hier'; String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override @override
String get map_pinLabel => 'Label vastzetten'; String get map_pinLabel => 'Label vastzetten';
@@ -1521,6 +1569,9 @@ class AppLocalizationsNl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Toon de voorspelde locaties van de knopen'; 'Toon de voorspelde locaties van de knopen';
@override
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
@override @override
String get map_guessedLocation => 'Geroerde locatie'; String get map_guessedLocation => 'Geroerde locatie';
+52
View File
@@ -117,6 +117,52 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Połącz przez USB'; String get usbScreenTitle => 'Połącz przez USB';
@@ -1466,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj'; String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override @override
String get map_pinLabel => 'Oznacz etykietę'; String get map_pinLabel => 'Oznacz etykietę';
@@ -1530,6 +1579,9 @@ class AppLocalizationsPl extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Wyświetl lokalizacje zgadanych węzłów'; 'Wyświetl lokalizacje zgadanych węzłów';
@override
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
@override @override
String get map_guessedLocation => 'Wydana lokalizacja'; String get map_guessedLocation => 'Wydana lokalizacja';
+51
View File
@@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Porta';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.';
@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 @override
String get usbScreenTitle => 'Conecte via USB'; String get usbScreenTitle => 'Conecte via USB';
@@ -1466,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartilhar marcador aqui'; String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override @override
String get map_pinLabel => 'Rótulo de marcador'; String get map_pinLabel => 'Rótulo de marcador';
@@ -1530,6 +1578,9 @@ class AppLocalizationsPt extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Mostrar as localizações dos nós estimados'; 'Mostrar as localizações dos nós estimados';
@override
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
@override @override
String get map_guessedLocation => 'Localização estimada'; String get map_guessedLocation => 'Localização estimada';
+51
View File
@@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Подключение через USB'; String get usbScreenTitle => 'Подключение через USB';
@@ -1468,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поделиться меткой здесь'; String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override @override
String get map_pinLabel => 'Метка'; String get map_pinLabel => 'Метка';
@@ -1532,6 +1580,9 @@ class AppLocalizationsRu extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Отобразить предполагаемые места расположения узлов'; 'Отобразить предполагаемые места расположения узлов';
@override
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
@override @override
String get map_guessedLocation => 'Угаданное место'; String get map_guessedLocation => 'Угаданное место';
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.';
@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 @override
String get usbScreenTitle => 'Pripojte cez USB'; String get usbScreenTitle => 'Pripojte cez USB';
@@ -1460,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Zdieľte značku tu'; String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override @override
String get map_pinLabel => 'Označka upozornenia'; String get map_pinLabel => 'Označka upozornenia';
@@ -1524,6 +1571,9 @@ class AppLocalizationsSk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Zobraziť umiestnenia odhadnutých uzlov'; 'Zobraziť umiestnenia odhadnutých uzlov';
@override
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
@override @override
String get map_guessedLocation => 'Odhadnutá lokalita'; String get map_guessedLocation => 'Odhadnutá lokalita';
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Vrata';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite';
@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 @override
String get usbScreenTitle => 'Povežite preko USB'; String get usbScreenTitle => 'Povežite preko USB';
@@ -1454,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Delite točke tukaj.'; String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override @override
String get map_pinLabel => 'Oznaka za pritrditev'; String get map_pinLabel => 'Oznaka za pritrditev';
@@ -1517,6 +1564,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.'; String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
@override
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
@override @override
String get map_guessedLocation => 'Predpostavljena lokacija'; String get map_guessedLocation => 'Predpostavljena lokacija';
+50
View File
@@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 => 'Port';
@override
String get tcpPortHint => '5000';
@override
String get tcpStatus_notConnected => 'Ange slutpunkt och anslut';
@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 @override
String get usbScreenTitle => 'Anslut via USB'; String get usbScreenTitle => 'Anslut via USB';
@@ -1450,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Dela markeringen här'; String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override @override
String get map_pinLabel => 'Fästetikett'; String get map_pinLabel => 'Fästetikett';
@@ -1514,6 +1561,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Visa upp de antagna nodernas placeringar'; 'Visa upp de antagna nodernas placeringar';
@override
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
@override @override
String get map_guessedLocation => 'Gissad plats'; String get map_guessedLocation => 'Gissad plats';
+51
View File
@@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => 'Bluetooth'; 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 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 @override
String get usbScreenTitle => 'Підключити через USB'; String get usbScreenTitle => 'Підключити через USB';
@@ -1465,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поділитися маркером тут'; String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override @override
String get map_pinLabel => 'Мітка піна'; String get map_pinLabel => 'Мітка піна';
@@ -1529,6 +1577,9 @@ class AppLocalizationsUk extends AppLocalizations {
String get map_showGuessedLocations => String get map_showGuessedLocations =>
'Показати місцезнаходження передбачених вузлів'; 'Показати місцезнаходження передбачених вузлів';
@override
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
@override @override
String get map_guessedLocation => 'Визначено місцезнаходження'; String get map_guessedLocation => 'Визначено місцезнаходження';
+49
View File
@@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get connectionChoiceBluetoothLabel => '蓝牙'; 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 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 @override
String get usbScreenTitle => '通过USB连接'; String get usbScreenTitle => '通过USB连接';
@@ -1378,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_shareMarkerHere => '在此分享标记'; String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override @override
String get map_pinLabel => '标签'; String get map_pinLabel => '标签';
@@ -1440,6 +1486,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showGuessedLocations => '显示猜测的节点位置'; String get map_showGuessedLocations => '显示猜测的节点位置';
@override
String get map_showDiscoveryContacts => '显示发现联系人';
@override @override
String get map_guessedLocation => '猜测的位置'; String get map_guessedLocation => '猜测的位置';
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_notConnected": "Selecteer een USB-apparaat",
"usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_connecting": "Verbinding maken met USB-apparaat...",
"usbStatus_searching": "Zoeken naar USB-apparaten...", "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": "Poort",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Voer het eindpunt in en verbind",
"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}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_connecting": "Połączenie z urządzeniem USB...",
"usbStatus_notConnected": "Wybierz urządzenie USB", "usbStatus_notConnected": "Wybierz urządzenie USB",
"usbConnectionFailed": "Błąd połączenia USB: {error}", "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_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}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_notConnected": "Selecione um dispositivo USB", "usbStatus_notConnected": "Selecione um dispositivo USB",
"usbConnectionFailed": "Falha na conexão USB: {error}", "usbConnectionFailed": "Falha na conexão USB: {error}",
"usbStatus_connecting": "Conectando ao dispositivo USB...", "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": "Porta",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
"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}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização"
} }
+30 -1
View File
@@ -1099,5 +1099,34 @@
"usbStatus_connecting": "Подключение к USB-устройству...", "usbStatus_connecting": "Подключение к USB-устройству...",
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
"usbStatus_notConnected": "Выберите USB-устройство", "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_connectingTo": "Подключение к {endpoint}...",
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
"usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_notConnected": "Vyberte USB zariadenie",
"usbStatus_connecting": "Pripojenie k USB zariadeniu...", "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": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
"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}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_connecting": "Povezava z USB napravo...",
"usbStatus_searching": "Iskanje USB naprav...", "usbStatus_searching": "Iskanje USB naprav...",
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", "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": "Vrata",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Vnesite končni naslov in se povežite",
"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}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_notConnected": "Välj en USB-enhet", "usbStatus_notConnected": "Välj en USB-enhet",
"usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbConnectionFailed": "Fel vid USB-anslutning: {error}",
"usbStatus_searching": "Söker efter USB-enheter...", "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": "Port",
"tcpPortHint": "5000",
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
"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}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats"
} }
+30 -1
View File
@@ -1859,5 +1859,34 @@
"usbStatus_notConnected": "Виберіть пристрій USB", "usbStatus_notConnected": "Виберіть пристрій USB",
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
"usbStatus_connecting": "Підключення до USB-пристрою...", "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_connectingTo": "Підключення до {endpoint}...",
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження"
} }
+30 -1
View File
@@ -1864,5 +1864,34 @@
"usbStatus_connecting": "连接USB设备...", "usbStatus_connecting": "连接USB设备...",
"usbStatus_notConnected": "选择一个 USB 设备", "usbStatus_notConnected": "选择一个 USB 设备",
"usbConnectionFailed": "USB 连接失败:{error}", "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_connectingTo": "连接到 {endpoint}...",
"tcpErrorHostRequired": "需要提供IP地址。",
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置"
} }
+8
View File
@@ -19,6 +19,7 @@ import 'services/app_debug_log_service.dart';
import 'services/background_service.dart'; import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart'; import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart'; import 'services/chat_text_scale_service.dart';
import 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@@ -39,6 +40,7 @@ void main() async {
final backgroundService = BackgroundService(); final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(); final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService(); final chatTextScaleService = ChatTextScaleService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
@@ -56,6 +58,7 @@ void main() async {
_registerThirdPartyLicenses(); _registerThirdPartyLicenses();
await chatTextScaleService.initialize(); await chatTextScaleService.initialize();
await timeoutPredictionService.initialize();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@@ -65,6 +68,7 @@ void main() async {
bleDebugLogService: bleDebugLogService, bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
backgroundService: backgroundService, backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
); );
await connector.loadContactCache(); await connector.loadContactCache();
@@ -86,6 +90,7 @@ void main() async {
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService, mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService, chatTextScaleService: chatTextScaleService,
timeoutPredictionService: timeoutPredictionService,
), ),
); );
} }
@@ -121,6 +126,7 @@ class MeshCoreApp extends StatelessWidget {
final AppDebugLogService appDebugLogService; final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService; final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService; final ChatTextScaleService chatTextScaleService;
final TimeoutPredictionService timeoutPredictionService;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@@ -133,6 +139,7 @@ class MeshCoreApp extends StatelessWidget {
required this.appDebugLogService, required this.appDebugLogService,
required this.mapTileCacheService, required this.mapTileCacheService,
required this.chatTextScaleService, required this.chatTextScaleService,
required this.timeoutPredictionService,
}); });
@override @override
@@ -148,6 +155,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: chatTextScaleService), ChangeNotifierProvider.value(value: chatTextScaleService),
Provider.value(value: storage), Provider.value(value: storage),
Provider.value(value: mapTileCacheService), Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) { builder: (context, settingsService, child) {
+8
View File
@@ -39,6 +39,7 @@ class AppSettings {
final Map<String, String> batteryChemistryByRepeaterId; final Map<String, String> batteryChemistryByRepeaterId;
final UnitSystem unitSystem; final UnitSystem unitSystem;
final Set<String> mutedChannels; final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -66,6 +67,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
this.unitSystem = UnitSystem.metric, this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels, Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}; mutedChannels = mutedChannels ?? {};
@@ -97,6 +99,7 @@ class AppSettings {
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId, 'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
'unit_system': unitSystem.value, 'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(), 'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
}; };
} }
@@ -152,6 +155,8 @@ class AppSettings {
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toSet()) ?? .toSet()) ??
{}, {},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
); );
} }
@@ -181,6 +186,7 @@ class AppSettings {
Map<String, String>? batteryChemistryByRepeaterId, Map<String, String>? batteryChemistryByRepeaterId,
UnitSystem? unitSystem, UnitSystem? unitSystem,
Set<String>? mutedChannels, Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -217,6 +223,8 @@ class AppSettings {
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId, batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
unitSystem: unitSystem ?? this.unitSystem, unitSystem: unitSystem ?? this.unitSystem,
mutedChannels: mutedChannels ?? this.mutedChannels, mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
); );
} }
} }
+10
View File
@@ -17,6 +17,8 @@ class Contact {
final double? longitude; final double? longitude;
final DateTime lastSeen; final DateTime lastSeen;
final DateTime lastMessageAt; final DateTime lastMessageAt;
final bool isActive;
final Uint8List? rawPacket;
Contact({ Contact({
required this.publicKey, required this.publicKey,
@@ -31,6 +33,8 @@ class Contact {
this.longitude, this.longitude,
required this.lastSeen, required this.lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
this.isActive = true,
this.rawPacket,
}) : lastMessageAt = lastMessageAt ?? lastSeen; }) : lastMessageAt = lastMessageAt ?? lastSeen;
String get publicKeyHex => pubKeyToHex(publicKey); String get publicKeyHex => pubKeyToHex(publicKey);
@@ -78,6 +82,8 @@ class Contact {
double? longitude, double? longitude,
DateTime? lastSeen, DateTime? lastSeen,
DateTime? lastMessageAt, DateTime? lastMessageAt,
bool? isActive,
Uint8List? rawPacket,
}) { }) {
return Contact( return Contact(
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
@@ -96,6 +102,8 @@ class Contact {
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
lastMessageAt: lastMessageAt ?? this.lastMessageAt, lastMessageAt: lastMessageAt ?? this.lastMessageAt,
isActive: isActive ?? this.isActive,
rawPacket: rawPacket ?? this.rawPacket,
); );
} }
@@ -204,6 +212,8 @@ class Contact {
latitude: lat, latitude: lat,
longitude: lon, longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
isActive: true,
rawPacket: null,
); );
} catch (e) { } catch (e) {
appLogger.error('Failed to parse contact frame: $e'); appLogger.error('Failed to parse contact frame: $e');
+43
View File
@@ -0,0 +1,43 @@
class DeliveryObservation {
final String contactKey;
final int pathLength;
final int messageBytes;
final int secondsSinceLastRx;
final bool isFlood;
final int deliveryMs;
final DateTime timestamp;
DeliveryObservation({
required this.contactKey,
required this.pathLength,
required this.messageBytes,
required this.secondsSinceLastRx,
required this.isFlood,
required this.deliveryMs,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'contact_key': contactKey,
'path_length': pathLength,
'message_bytes': messageBytes,
'seconds_since_last_rx': secondsSinceLastRx,
'is_flood': isFlood,
'delivery_ms': deliveryMs,
'timestamp': timestamp.toIso8601String(),
};
}
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
return DeliveryObservation(
contactKey: json['contact_key'] as String,
pathLength: json['path_length'] as int,
messageBytes: json['message_bytes'] as int,
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
isFlood: json['is_flood'] as bool,
deliveryMs: json['delivery_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
-105
View File
@@ -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 = <String>[];
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;
}
+13
View File
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
: Icons.download, : Icons.download,
size: 18, size: 18,
), ),
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
text: entry.payload
.map(
(b) => b
.toRadixString(16)
.padLeft(2, '0'),
)
.join(''),
),
);
},
); );
} }
+10 -7
View File
@@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = <Contact>[
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); ...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
primaryPath.length, primaryPath.length,
@@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops( final contacts = <Contact>[
selectedPath, ...connector.contacts,
connector.contacts, ...connector.discoveredContacts,
context.l10n, ];
); final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[]; final points = <LatLng>[];
+32 -10
View File
@@ -51,6 +51,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
// Cache of PSK hex -> Community for quick lookup // Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {}; final Map<String, Community> _pskToCommunity = {};
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
Future<void> _loadCommunities() async { Future<void> _loadCommunities() async {
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
final communities = await _communityStore.loadCommunities(); final communities = await _communityStore.loadCommunities();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -714,6 +718,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
bool isRegularHashtag = true; bool isRegularHashtag = true;
Community? selectedCommunity; Community? selectedCommunity;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
@@ -765,7 +771,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
} }
Widget? buildExpandedContent() { Widget? buildExpandedContent(
ChannelMessageStore channelMessageStore,
) {
switch (selectedOption) { switch (selectedOption) {
case 0: // Create Private Channel case 0: // Create Private Channel
return Column( return Column(
@@ -790,7 +798,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
children: [ children: [
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: () async {
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -812,7 +820,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
psk[i] = random.nextInt(256); psk[i] = random.nextInt(256);
} }
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
connector.setChannel(nextIndex, name, psk); await connector.setChannel(
nextIndex,
name,
psk,
);
await channelMessageStore.clearChannelMessages(
nextIndex,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -1331,7 +1346,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_createPrivateChannelDesc, dialogContext.l10n.channels_createPrivateChannelDesc,
), ),
if (selectedOption == 0) buildExpandedContent()!, if (selectedOption == 0)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 1, optionIndex: 1,
@@ -1340,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPrivateChannelDesc, dialogContext.l10n.channels_joinPrivateChannelDesc,
), ),
if (selectedOption == 1) buildExpandedContent()!, if (selectedOption == 1)
buildExpandedContent(_channelMessageStore)!,
if (!hasPublicChannel) ...[ if (!hasPublicChannel) ...[
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1350,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinPublicChannelDesc, dialogContext.l10n.channels_joinPublicChannelDesc,
), ),
if (selectedOption == 2) buildExpandedContent()!, if (selectedOption == 2)
buildExpandedContent(_channelMessageStore)!,
], ],
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
@@ -1360,7 +1378,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
subtitle: subtitle:
dialogContext.l10n.channels_joinHashtagChannelDesc, dialogContext.l10n.channels_joinHashtagChannelDesc,
), ),
if (selectedOption == 3) buildExpandedContent()!, if (selectedOption == 3)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 4, optionIndex: 4,
@@ -1368,7 +1387,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_scanQr, title: dialogContext.l10n.community_scanQr,
subtitle: dialogContext.l10n.community_join, subtitle: dialogContext.l10n.community_join,
), ),
if (selectedOption == 4) buildExpandedContent()!, if (selectedOption == 4)
buildExpandedContent(_channelMessageStore)!,
const Divider(height: 1), const Divider(height: 1),
buildOptionTile( buildOptionTile(
optionIndex: 5, optionIndex: 5,
@@ -1376,7 +1396,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
title: dialogContext.l10n.community_create, title: dialogContext.l10n.community_create,
subtitle: dialogContext.l10n.community_createDesc, subtitle: dialogContext.l10n.community_createDesc,
), ),
if (selectedOption == 5) buildExpandedContent()!, if (selectedOption == 5)
buildExpandedContent(_channelMessageStore)!,
], ],
), ),
), ),
@@ -1526,7 +1547,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
try { try {
await connector.deleteChannel(channel.index); await connector.deleteChannel(channel.index);
channelMessageStore.clearChannelMessages(channel.index); await channelMessageStore.clearChannelMessages(channel.index);
if (!context.mounted) return; if (!context.mounted) return;
@@ -1751,6 +1772,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
final channelCount = communityChannels.length; final channelCount = communityChannels.length;
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
showDialog( showDialog(
context: context, context: context,
+74 -30
View File
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have non-empty path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1; final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode, tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else { } else {
await connector.setPathOverride(contact, pathLen: null); await connector.setPathOverride(contact, pathLen: null);
} }
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: activeMode == 'auto'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( 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.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: activeMode == 'flood'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode fontWeight: activeMode == 'flood'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_sendMessageTo(widget.contact.name), context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Auto-scroll to bottom if user is already at bottom // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom(); _scrollController.scrollToBottomIfAtBottom();
}); });
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final messageIndex = index; final messageIndex = index;
Contact contact = widget.contact; Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>( final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final resolvedContact = _resolveContact(connector);
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage(widget.contact, text); connector.sendMessage(_resolveContact(connector), text);
_textController.clear(); _textController.clear();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
} }
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: pathLength, pathLen: pathLength,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet( await _notifyPathSet(
connector, connector,
widget.contact, _resolveContact(connector),
pathBytes, pathBytes,
path.hopCount, path.hopCount,
); );
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
onTap: () async { onTap: () async {
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (result == null) { if (result == null) {
appLogger.info( return; // Cancelled keep existing path
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
} }
if (!mounted) { if (!mounted) {
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen', tag: 'ChatScreen',
); );
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: result.length, pathLen: result.length,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return; 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) { void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName; final String senderName;
if (message.isOutgoing) { if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me; senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) { } else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]"; senderName = "${contact.name} [$fourByteHex]";
} else { } else {
senderName = widget.contact.name; senderName = _resolveContact(connector).name;
} }
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom) if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text); connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users // For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // 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 ? senderContact.name
: null; : null;
final hash = ReactionHelper.computeReactionHash( final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(_resolveContact(connector), reactionText);
} }
} }
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_isProcessing = true; _isProcessing = true;
}); });
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
try { try {
// Parse the community data // Parse the community data
final community = Community.fromQrData(const Uuid().v4(), data); final community = Community.fromQrData(const Uuid().v4(), data);
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
bool addPublicChannel, bool addPublicChannel,
) async { ) async {
// Save community to local storage // Save community to local storage
final connector = context.read<MeshCoreConnector>();
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community); await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device // Optionally add the community public channel to the device
+8
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.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/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -64,6 +65,13 @@ class _ContactsScreenState extends State<ContactsScreen>
super.initState(); super.initState();
_loadGroups(); _loadGroups();
_setupFrameListener(); _setupFrameListener();
_clearAdvertNotifications();
}
void _clearAdvertNotifications() {
final connector = context.read<MeshCoreConnector>();
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
NotificationService().clearAdvertNotifications(contactIds);
} }
@override @override
+8 -7
View File
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Future<void> _showContactContextMenu( Future<void> _showContactContextMenu(
DiscoveryContact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showModalBottomSheet<String>(
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
connector.importDiscoveredContact(contact); connector.importDiscoveredContact(contact);
break; break;
case 'copy_contact': case 'copy_contact':
final hexString = pubKeyToHex(contact.rawPacket); if (contact.rawPacket == null) return;
final hexString = pubKeyToHex(contact.rawPacket!);
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
} }
Widget _buildFilters( Widget _buildFilters(
List<DiscoveryContact> filteredAndSorted, List<Contact> filteredAndSorted,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
String hintText = ""; String hintText = "";
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
); );
} }
List<DiscoveryContact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<DiscoveryContact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return filtered; return filtered;
} }
bool _matchesTypeFilter(DiscoveryContact contact) { bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
+137 -39
View File
@@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
} }
class _MapScreenState extends State<MapScreen> { class _MapScreenState extends State<MapScreen> {
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 MapController _mapController = MapController();
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
@@ -91,6 +93,15 @@ class _MapScreenState extends State<MapScreen> {
}); });
} }
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<double> values) { double _standardDeviation(List<double> values) {
if (values.length <= 1) { if (values.length <= 1) {
return 0.0; return 0.0;
@@ -126,7 +137,15 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) { builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings; final settings = settingsService.settings;
final contacts = connector.contacts; final allContacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts.where((c) => !c.isActive),
];
final contacts = settings.mapShowDiscoveryContacts
? allContacts
: allContacts.where((c) => c.isActive).toList();
final highlightPosition = widget.highlightPosition; final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector) ? _collectSharedMarkers(connector)
@@ -159,14 +178,21 @@ class _MapScreenState extends State<MapScreen> {
: filteredByTime; : filteredByTime;
// Filter by location // Filter by location
final contactsWithLocation = filteredByKeyPrefix final contactsWithLocation = filteredByKeyPrefix.where((c) {
.where((c) => c.hasLocation) if (!c.hasLocation) {
.toList(); return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
}).toList();
// All contacts with a known location used as anchors regardless of // All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available. // time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = contacts final allContactsWithLocation = allContacts
.where((c) => c.hasLocation) .where(
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.toList(); .toList();
// Compute guessed locations with caching // Compute guessed locations with caching
@@ -468,7 +494,10 @@ class _MapScreenState extends State<MapScreen> {
), ),
), ),
if (!_isBuildingPathTrace) if (!_isBuildingPathTrace)
...guessedLocations.map(_buildGuessedMarker), ..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers( ..._buildMarkers(
contactsWithLocation, contactsWithLocation,
settings, settings,
@@ -630,6 +659,13 @@ class _MapScreenState extends State<MapScreen> {
anchors[0].latitude + offsetDeg * cos(angle), anchors[0].latitude + offsetDeg * cos(angle),
anchors[0].longitude + offsetDeg * sin(angle), anchors[0].longitude + offsetDeg * sin(angle),
); );
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0)
}
} else { } else {
double lat = 0, lon = 0; double lat = 0, lon = 0;
for (final a in anchors) { for (final a in anchors) {
@@ -637,6 +673,12 @@ class _MapScreenState extends State<MapScreen> {
lon += a.longitude; lon += a.longitude;
} }
position = LatLng(lat / anchors.length, lon / anchors.length); position = LatLng(lat / anchors.length, lon / anchors.length);
if (!_checkLocationPlausibility(
position.latitude,
position.longitude,
)) {
continue; // discard implausible guesses near (0, 0
}
} }
result.add( result.add(
_GuessedLocation( _GuessedLocation(
@@ -710,40 +752,61 @@ class _MapScreenState extends State<MapScreen> {
.toList(); .toList();
} }
Marker _buildGuessedMarker(_GuessedLocation guess) { List<Marker> _buildGuessedMarker(
final color = _getNodeColor(guess.contact.type); List<_GuessedLocation> guessed, {
return Marker( required bool showLabels,
point: guess.position, }) {
width: 35, final markers = <Marker>[];
height: 35,
child: GestureDetector( for (final guess in guessed) {
onTap: () => _showNodeInfo( final color = _getNodeColor(guess.contact.type);
context, final marker = Marker(
guess.contact, point: guess.position,
guessedPosition: guess.position, width: 35,
), height: 35,
child: Container( child: GestureDetector(
padding: const EdgeInsets.all(4), onTap: () => _showNodeInfo(
decoration: BoxDecoration( context,
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30), guess.contact,
shape: BoxShape.circle, guessedPosition: guess.position,
border: Border.all(color: Colors.white, width: 2), ),
boxShadow: [ child: Container(
BoxShadow( padding: const EdgeInsets.all(4),
color: Colors.black.withValues(alpha: 0.3), decoration: BoxDecoration(
blurRadius: 4, color: color.withValues(
offset: const Offset(0, 2), alpha: guess.highConfidence ? 0.55 : 0.30,
), ),
], shape: BoxShape.circle,
), border: Border.all(color: Colors.white, width: 2),
child: const Icon( boxShadow: [
Icons.not_listed_location, BoxShadow(
color: Colors.white, color: Colors.black.withValues(alpha: 0.3),
size: 20, 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<Marker> _buildMarkers( List<Marker> _buildMarkers(
@@ -1203,6 +1266,7 @@ class _MapScreenState extends State<MapScreen> {
Contact contact, { Contact contact, {
LatLng? guessedPosition, LatLng? guessedPosition,
}) { }) {
final connector = context.read<MeshCoreConnector>();
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -1248,6 +1312,9 @@ class _MapScreenState extends State<MapScreen> {
advTypeChat) // Only show chat button for chat nodes advTypeChat) // Only show chat button for chat nodes
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
Navigator.push( Navigator.push(
context, context,
@@ -1261,6 +1328,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRepeater) if (contact.type == advTypeRepeater)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
}, },
@@ -1269,6 +1339,9 @@ class _MapScreenState extends State<MapScreen> {
if (contact.type == advTypeRoom) if (contact.type == advTypeRoom)
TextButton( TextButton(
onPressed: () { onPressed: () {
if (!contact.isActive) {
connector.importDiscoveredContact(contact);
}
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
_showRoomLogin(context, contact); _showRoomLogin(context, contact);
}, },
@@ -1436,6 +1509,23 @@ class _MapScreenState extends State<MapScreen> {
); );
}, },
), ),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel), title: Text(context.l10n.common_cancel),
@@ -1745,6 +1835,14 @@ class _MapScreenState extends State<MapScreen> {
}, },
contentPadding: EdgeInsets.zero, 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), const SizedBox(height: 16),
Text( Text(
context.l10n.map_keyPrefix, context.l10n.map_keyPrefix,
+5 -3
View File
@@ -124,12 +124,14 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
try { try {
final neighborCount = buffer.readUInt16LE(); final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
connector.contacts.where((c) => c.type == advTypeRepeater).forEach(( contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
repeater,
) {
for (var neighborData in parsedNeighbors) { for (var neighborData in parsedNeighbors) {
final publicKey = neighborData['publicKey']; final publicKey = neighborData['publicKey'];
if (listEquals( if (listEquals(
+42 -13
View File
@@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
super.dispose(); super.dispose();
} }
Uint8List addReturnPath(Uint8List pathBytes) { Uint8List buildPath(Uint8List pathBytes) {
Uint8List? traceBytes; Uint8List traceBytes;
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); if (pathBytes.isEmpty) {
for (int i = 0; i < pathBytes.length; i++) { traceBytes = Uint8List(1);
traceBytes[i] = pathBytes[i]; traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
if (i < pathBytes.length - 1) { return traceBytes;
traceBytes[len - 1 - i] = pathBytes[i]; }
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; return traceBytes;
@@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
: widget.path; : widget.path;
if (widget.flipPathRound) { if (widget.flipPathRound) {
path = addReturnPath(pathTmp); path = buildPath(pathTmp);
} else { } else {
path = pathTmp; path = pathTmp;
} }
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq( final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000, DateTime.now().millisecondsSinceEpoch ~/ 1000,
@@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList(); .toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
final contacts = <Contact>[
connector.contacts.where((c) => c.type != advTypeChat).forEach(( ...connector.contacts,
repeater, ...connector.discoveredContacts,
) { ];
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
+67 -49
View File
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
/// Screen for scanning and connecting to MeshCore devices /// Screen for scanning and connecting to MeshCore devices
@@ -125,61 +126,78 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.state == MeshCoreConnectionState.scanning; connector.state == MeshCoreConnectionState.scanning;
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
final usbSupported = PlatformInfo.supportsUsbSerial; final usbSupported = PlatformInfo.supportsUsbSerial;
final tcpSupported = !PlatformInfo.isWeb;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row( child: FittedBox(
mainAxisAlignment: MainAxisAlignment.end, fit: BoxFit.scaleDown,
children: [ alignment: Alignment.centerRight,
if (usbSupported) 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( FloatingActionButton.extended(
onPressed: () { heroTag: 'scanner_ble_action',
appLogger.info( onPressed: isBluetoothOff
'USB selected, opening UsbScreen', ? null
tag: 'ScannerScreen', : () {
); if (isScanning) {
Navigator.of(context).push( connector.stopScan();
MaterialPageRoute(builder: (_) => const UsbScreen()), } else {
); unawaited(
}, connector.startScan().catchError((e) {
heroTag: 'scanner_usb_action', appLogger.warn(
icon: const Icon(Icons.usb), 'startScan error: $e',
label: Text(context.l10n.connectionChoiceUsbLabel), 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,
),
),
],
), ),
); );
}, },
+282
View File
@@ -0,0 +1,282 @@
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<TcpScreen> createState() => _TcpScreenState();
}
class _TcpScreenState extends State<TcpScreen> {
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();
_portController = TextEditingController(text: '5000');
_connector = context.read<MeshCoreConnector>();
_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<MeshCoreConnector>(
builder: (context, connector, child) {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
final isButtonDisabled =
isConnecting ||
connector.state == MeshCoreConnectionState.scanning;
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(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? 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',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
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',
extendedPadding: const EdgeInsets.symmetric(horizontal: 12),
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<void> _connectTcp() async {
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());
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());
}
}
+72 -38
View File
@@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'scanner_screen.dart'; import 'scanner_screen.dart';
import 'tcp_screen.dart';
class UsbScreen extends StatefulWidget { class UsbScreen extends StatefulWidget {
const UsbScreen({super.key}); const UsbScreen({super.key});
@@ -107,45 +108,69 @@ class _UsbScreenState extends State<UsbScreen> {
bottomNavigationBar: Consumer<MeshCoreConnector>( bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isLoading = _isLoadingPorts; final isLoading = _isLoadingPorts;
final showBle = final showBle = true;
PlatformInfo.isWeb || final showTcp = !PlatformInfo.isWeb;
PlatformInfo.isAndroid ||
PlatformInfo.isIOS;
return SafeArea( return SafeArea(
top: false, top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row( child: FittedBox(
mainAxisAlignment: MainAxisAlignment.end, fit: BoxFit.scaleDown,
children: [ alignment: Alignment.centerRight,
if (showBle) child: Row(
FloatingActionButton.extended( mainAxisAlignment: MainAxisAlignment.end,
onPressed: () { children: [
Navigator.of(context).pushReplacement( if (showTcp)
MaterialPageRoute( FloatingActionButton.extended(
builder: (_) => const ScannerScreen(), onPressed: () {
), Navigator.of(context).pushReplacement(
); MaterialPageRoute(builder: (_) => const TcpScreen()),
}, );
heroTag: 'usb_ble_action', },
icon: const Icon(Icons.bluetooth), heroTag: 'usb_tcp_action',
label: Text(context.l10n.connectionChoiceBluetoothLabel), extendedPadding: const EdgeInsets.symmetric(
), horizontal: 12,
if (showBle) const SizedBox(width: 12), ),
if (!_supportsHotPlug) icon: const Icon(Icons.lan),
FloatingActionButton.extended( label: Text(context.l10n.connectionChoiceTcpLabel),
onPressed: isLoading ? null : _loadPorts, ),
heroTag: 'usb_refresh_action', if (showTcp && showBle) const SizedBox(width: 12),
icon: isLoading if (showBle)
? const SizedBox( FloatingActionButton.extended(
width: 20, onPressed: () {
height: 20, Navigator.of(context).pushReplacement(
child: CircularProgressIndicator(strokeWidth: 2), MaterialPageRoute(
) builder: (_) => const ScannerScreen(),
: const Icon(Icons.refresh), ),
label: Text(context.l10n.repeater_refresh), );
), },
], 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 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
children: [ children: [
Icon(Icons.circle, size: 12, color: statusColor), Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
statusText, child: FittedBox(
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
),
), ),
], ],
), ),
+4
View File
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setMapShowDiscoveryContacts(bool value) async {
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
}
Future<void> setBatteryChemistryForDevice( Future<void> setBatteryChemistryForDevice(
String deviceId, String deviceId,
String chemistry, String chemistry,
+178 -54
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId = final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -52,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback; Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback; String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService(); MessageRetryService();
@@ -67,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback, Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback, String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService, AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
@@ -85,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_debugLogService = debugLogService; _debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
} }
/// Compute expected ACK hash using same algorithm as firmware: /// Compute expected ACK hash using same algorithm as firmware:
@@ -156,7 +172,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message); _addMessageCallback!(contact.publicKeyHex, message);
} }
await _attemptSend(messageId); // Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
if (!_activeMessages.any(
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
)) {
_sendNextForContact(contactKey);
}
}
void _sendNextForContact(String contactKey) {
final queue = _sendQueue[contactKey];
if (queue == null) return;
// Drain stale entries iteratively instead of recursing.
while (queue.isNotEmpty) {
final messageId = queue.removeAt(0);
if (_pendingMessages.containsKey(messageId)) {
_activeMessages.add(messageId);
_attemptSend(messageId).catchError((e) {
debugPrint('_attemptSend threw for $messageId: $e');
final msg = _pendingMessages[messageId];
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
_sendNextForContact(contactKey);
} }
Future<void> _attemptSend(String messageId) async { Future<void> _attemptSend(String messageId) async {
@@ -169,13 +227,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent // Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint( debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}', 'Setting flood mode for retry attempt ${message.retryCount}',
); );
_clearContactPathCallback!(contact); await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes : message.pathBytes
@@ -192,6 +248,24 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
// Re-validate after async gap a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages[messageId];
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
debugPrint(
'_attemptSend: message $messageId resolved during path sync, aborting',
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
);
return;
}
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +305,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds); _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
} }
} }
@@ -281,6 +364,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // 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) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +374,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching', 'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
); );
for (var entry in _pendingMessageQueuePerContact.entries) { // Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key; final contactKey = entry.key;
final queue = entry.value; final queue = entry.value;
if (queue.isNotEmpty) { // Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0); final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
@@ -304,21 +391,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
); );
break; break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
} }
debugPrint('Dequeued stale message $candidateMessageId - skipping');
} }
if (messageId != null) break;
} }
} }
@@ -357,25 +433,33 @@ class MessageRetryService extends ChangeNotifier {
); );
} }
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) { if (_calculateTimeoutCallback != null) {
int pathLengthValue; final calculated = _calculateTimeoutCallback!(
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!(
pathLengthValue, pathLengthValue,
message.text.length, message.text.length,
contactKey: contact.publicKeyHex,
); );
debugPrint( // calculateTimeout tries ML first, falls back to physics.
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue', // 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( final updatedMessage = message.copyWith(
@@ -463,22 +547,7 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// 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);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +568,30 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
} }
} }
@@ -594,7 +687,15 @@ class MessageRetryService extends ChangeNotifier {
} }
if (matchedMessageId != null) { 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 contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +717,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, 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 // Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId); _moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
@@ -646,6 +756,17 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
notifyListeners(); notifyListeners();
@@ -783,6 +904,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear(); _ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear(); _pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose(); super.dispose();
} }
} }
+58 -1
View File
@@ -232,7 +232,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -331,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id); await _notifications.cancel(id: id);
} }
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
// //
// Public notification methods (rate limiting is enforced automatically) // Public notification methods (rate limiting is enforced automatically)
// //
+31
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import '../models/delivery_observation.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../storage/prefs_manager.dart'; import '../storage/prefs_manager.dart';
@@ -6,6 +7,7 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_'; static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages'; static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, String contactPubKeyHex,
@@ -122,4 +124,33 @@ class StorageService {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.remove(_repeaterPasswordsKey); await prefs.remove(_repeaterPasswordsKey);
} }
Future<void> saveDeliveryObservations(
List<DeliveryObservation> observations,
) async {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
await prefs.setString(_deliveryObservationsKey, jsonStr);
}
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_deliveryObservationsKey);
if (jsonStr == null) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> clearDeliveryObservations() async {
final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey);
}
} }
+2
View File
@@ -0,0 +1,2 @@
export 'tcp_transport_service_native.dart'
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
@@ -0,0 +1,210 @@
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<Uint8List> _frameController =
StreamController<Uint8List>.broadcast();
final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder();
StreamSubscription<Uint8List>? _socketSubscription;
Socket? _socket;
AppDebugLogService? _debugLogService;
TcpTransportStatus _status = TcpTransportStatus.disconnected;
String? _activeHost;
int? _activePort;
Future<void> _pendingWrite = Future<void>.value();
int _connectGeneration = 0;
TcpTransportStatus get status => _status;
Stream<Uint8List> 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<void> 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<void> 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<void> 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<void> _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() {
if (_status == TcpTransportStatus.disconnecting ||
_status == TcpTransportStatus.disconnected) {
return;
}
_addFrameError(StateError('TCP socket closed by remote endpoint'));
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<void> _closeFrameController() async {
if (_frameController.isClosed) return;
await _frameController.close();
}
}
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'app_debug_log_service.dart';
class TcpTransportService {
AppDebugLogService? _debugLogService;
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
bool get isConnected => false;
String? get activeEndpoint => null;
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
Future<void> 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<void> write(Uint8List data) async {
throw UnsupportedError('TCP transport is not supported on web.');
}
Future<void> disconnect() async {}
void dispose() {}
}
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ class ChannelMessageStore {
final key = '$keyFor$channelIndex'; final key = '$keyFor$channelIndex';
final oldKey = '$_keyPrefix$channelIndex'; final oldKey = '$_keyPrefix$channelIndex';
String? jsonString = prefs.getString(oldKey); String? jsonString = prefs.getString(key);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey); final legacyJsonString = prefs.getString(oldKey);
+1 -1
View File
@@ -26,7 +26,7 @@ class ChannelOrderStore {
return []; return [];
} }
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(_keyPrefix); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
+1 -1
View File
@@ -32,7 +32,7 @@ class ChannelSettingsStore {
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
} }
} }
return prefs.getBool(key) ?? false; return enabled ?? false;
} }
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async { Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
+1 -1
View File
@@ -19,7 +19,7 @@ class ChannelStore {
return []; return [];
} }
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(_keyPrefix); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
+1 -1
View File
@@ -25,7 +25,7 @@ class CommunityStore {
return []; return [];
} }
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(_keyPrefix); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
+30 -8
View File
@@ -1,13 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/discovery_contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _keyPrefix = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
Future<List<DiscoveryContact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_keyPrefix); final jsonStr = prefs.getString(_keyPrefix);
if (jsonStr == null) return []; if (jsonStr == null) return [];
@@ -22,40 +22,62 @@ class ContactDiscoveryStore {
} }
} }
Future<void> saveContacts(List<DiscoveryContact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_keyPrefix, jsonEncode(jsonList)); await prefs.setString(_keyPrefix, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(DiscoveryContact contact) { Map<String, dynamic> _toJson(Contact contact) {
return { return {
'rawPacket': base64Encode(contact.rawPacket),
'publicKey': base64Encode(contact.publicKey), 'publicKey': base64Encode(contact.publicKey),
'name': contact.name, 'name': contact.name,
'type': contact.type, 'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength, 'pathLength': contact.pathLength,
'path': base64Encode(contact.path), 'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
? base64Encode(contact.pathOverrideBytes!)
: null,
'latitude': contact.latitude, 'latitude': contact.latitude,
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
'rawPacket': contact.rawPacket != null
? base64Encode(contact.rawPacket!)
: null,
}; };
} }
DiscoveryContact _fromJson(Map<String, dynamic> json) { Contact _fromJson(Map<String, dynamic> json) {
final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastSeenMs = json['lastSeen'] as int? ?? 0;
return DiscoveryContact( final lastMessageMs = json['lastMessageAt'] as int?;
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)), return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown', name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0, type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1, pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null path: json['path'] != null
? Uint8List.fromList(base64Decode(json['path'] as String)) ? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0), : 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(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
isActive: false,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ class ContactGroupStore {
return []; return [];
} }
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(_keyPrefix); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
+8
View File
@@ -76,6 +76,10 @@ class ContactStore {
'longitude': contact.longitude, 'longitude': contact.longitude,
'lastSeen': contact.lastSeen.millisecondsSinceEpoch, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
'lastMessageAt': contact.lastMessageAt.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( lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs, lastMessageMs ?? lastSeenMs,
), ),
isActive: json['isActive'] as bool? ?? true,
rawPacket: json['rawPacket'] != null
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
: null,
); );
} }
} }
+1 -1
View File
@@ -32,7 +32,7 @@ class UnreadStore {
return {}; return {};
} }
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(_keyPrefix); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
+1 -3
View File
@@ -1,5 +1,3 @@
import 'package:meshcore_open/models/discovery_contact.dart';
import '../models/contact.dart'; import '../models/contact.dart';
bool matchesContactQuery(Contact contact, String query) { bool matchesContactQuery(Contact contact, String query) {
@@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) {
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix); return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
} }
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) { bool matchesDiscoveryContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
+5 -2
View File
@@ -157,8 +157,11 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr, repeater.snr,
widget.connector.currentSf, widget.connector.currentSf,
); );
final allContacts = [
final name = widget.connector.contacts ...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name) .map((c) => c.name)
.firstOrNull; .firstOrNull;
+2
View File
@@ -69,6 +69,8 @@ dependencies:
material_symbols_icons: ^4.2906.0 material_symbols_icons: ^4.2906.0
web: ^1.1.1 web: ^1.1.1
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -0,0 +1,93 @@
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);
});
});
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);
});
});
}
+192
View File
@@ -0,0 +1,192 @@
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<void> 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<MeshCoreConnector>.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.byKey(const Key('tcp_connect_button')));
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.byKey(const Key('tcp_connect_button')));
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<NavigatorState>(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 disables connect button 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();
final button = tester.widget<ButtonStyleButton>(
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', (
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));
});
}
+65 -18
View File
@@ -116,12 +116,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(find.byType(ListTile).first);
find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
),
);
await tester.pump(); await tester.pump();
expect(connector.connectUsbCalls, 0); expect(connector.connectUsbCalls, 0);
@@ -145,12 +140,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(find.byType(ListTile).first);
find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
),
);
await tester.pump(); await tester.pump();
expect(connector.connectUsbCalls, 1); expect(connector.connectUsbCalls, 1);
@@ -179,6 +169,68 @@ void main() {
await tester.pump(const Duration(milliseconds: 60)); 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', () { group('Error Handling', () {
testWidgets('shows error SnackBar when listing ports fails', ( testWidgets('shows error SnackBar when listing ports fails', (
tester, tester,
@@ -212,12 +264,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(find.byType(ListTile).first);
find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(connectAttempted, isTrue); expect(connectAttempted, isTrue);
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
void main() {
test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100
final data = DataFrame(
[
[1.0, 102.0],
[2.0, 104.0],
[3.0, 106.0],
[4.0, 108.0],
[5.0, 110.0],
[10.0, 120.0],
[20.0, 140.0],
[50.0, 200.0],
[0.0, 100.0],
[100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}');
final model = LinearRegressor(data, 'y');
final testDf = DataFrame(
[
[25.0],
],
headerExists: false,
header: ['x'],
);
final prediction = model.predict(testDf);
final value = prediction.rows.first.first;
debugPrint('Predict x=25 → y=$value (expected ~150)');
expect((value as num).toDouble(), closeTo(150, 5));
});
test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 14.0, 0.0, 2200.0],
[2.0, 50.0, 14.0, 0.0, 5000.0],
[4.0, 50.0, 14.0, 0.0, 9500.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame(
[
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
});
test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs
final data = DataFrame(
[
[0.0, 50.0, 1900.0],
[0.0, 80.0, 2200.0],
[2.0, 50.0, 5000.0],
[2.0, 80.0, 5500.0],
[4.0, 50.0, 9500.0],
[4.0, 80.0, 10000.0],
[0.0, 30.0, 1800.0],
[2.0, 30.0, 4800.0],
[4.0, 30.0, 9000.0],
[0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame(
[
[hops, 50.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint('2-feature: hops=$hops${(pred as num).round()}ms');
}
});
test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 50.0, 16.0, 0.0, 5000.0],
[2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 50.0, 8.0, 0.0, 9500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final tc in [
[0.0, 50.0, 14.0, 0.0],
[2.0, 50.0, 14.0, 0.0],
[4.0, 50.0, 14.0, 0.0],
[-1.0, 50.0, 14.0, 1.0],
]) {
final testDf = DataFrame(
[tc],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
}
});
}
@@ -0,0 +1,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<Socket> socketConnect(
host,
int port, {
sourceAddress,
int sourcePort = 0,
Duration? timeout,
}) async {
await Future<void>.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<Socket>();
final service = TcpTransportService();
final receivedFrames = <Uint8List>[];
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(<int>[usbSerialTxFrameStart, 0x01, 0x00, 0x11]);
socket.add(<int>[usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]);
await socket.flush();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(receivedFrames, hasLength(1));
expect(receivedFrames.single, orderedEquals(<int>[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<void>.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();
}
},
);
}
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/delivery_observation.dart';
import 'package:meshcore_open/services/timeout_prediction_service.dart';
void main() {
late TimeoutPredictionService service;
setUp(() {
service = TimeoutPredictionService.noStorage();
});
test('trains on sample data and predicts sensible timeouts', () {
// Simulate realistic delivery data:
// Direct 0-hop messages: ~1500-2500ms
// 2-hop messages: ~4000-6000ms
// 4-hop messages: ~8000-12000ms
// Flood messages: ~3000-8000ms
final sampleData = [
// 0-hop direct
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
// 2-hop direct
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
// 4-hop direct
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
// Flood
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
];
// Feed all observations
for (final obs in sampleData) {
service.recordObservation(
contactKey: obs.contactKey,
pathLength: obs.pathLength,
messageBytes: obs.messageBytes,
tripTimeMs: obs.deliveryMs,
);
}
expect(service.hasModel, isTrue);
expect(service.observationCount, equals(12));
// Predict for different scenarios
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
// All should return non-null (model is trained)
expect(direct0, isNotNull);
expect(direct2, isNotNull);
expect(direct4, isNotNull);
expect(flood, isNotNull);
// More hops should predict longer timeouts
expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!));
// All should be positive
expect(direct0, greaterThan(0));
expect(direct4, greaterThan(0));
// Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):');
debugPrint(' 0-hop direct: ${direct0}ms');
debugPrint(' 2-hop direct: ${direct2}ms');
debugPrint(' 4-hop direct: ${direct4}ms');
debugPrint(' flood: ${flood}ms');
});
test('returns null before minimum observations', () {
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000,
);
}
expect(service.hasModel, isFalse);
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
});
test('caps observations at maxObservations', () {
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000 + i,
);
}
expect(
service.observationCount,
equals(TimeoutPredictionService.maxObservations),
);
});
test('blends per-contact stats after enough observations', () {
// Train with mixed contacts and varied features:
// contactA is fast (0-hop), contactB is slow (2-hop)
for (var i = 0; i < 12; i++) {
service.recordObservation(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 30 + i,
tripTimeMs: 1500,
);
service.recordObservation(
contactKey: 'contactB',
pathLength: 2,
messageBytes: 30 + i,
tripTimeMs: 8000,
);
}
final predA = service.predictTimeout(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 50,
);
final predB = service.predictTimeout(
contactKey: 'contactB',
pathLength: 0,
messageBytes: 50,
);
expect(predA, isNotNull);
expect(predB, isNotNull);
// Contact B (slow) should have a higher predicted timeout than A (fast)
expect(predB!, greaterThan(predA!));
debugPrint('Per-contact blending:');
debugPrint(' contactA (fast): ${predA}ms');
debugPrint(' contactB (slow): ${predB}ms');
});
}
DeliveryObservation _obs({
required int pathLength,
required int messageBytes,
required int deliveryMs,
String contactKey = 'test_contact',
}) {
return DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: 5,
isFlood: pathLength < 0,
deliveryMs: deliveryMs,
timestamp: DateTime.now(),
);
}