mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-15 07:04:26 +10:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa4da979af | |||
| 91608ff09e | |||
| 71f59d23df | |||
| e90742be25 | |||
| db935a7454 | |||
| 1ad5db27ca | |||
| 81758adc61 | |||
| c81791cf1e | |||
| 1fba5312a2 | |||
| 2f770bbd53 | |||
| 9db79e9d40 | |||
| 1913a5aa11 | |||
| 929c1c3d28 | |||
| 7a2bb20bf7 | |||
| a1b77bb29b | |||
| 4eecfc92dc | |||
| 90c8cf5f3e | |||
| 06fa176367 | |||
| e4285774a0 | |||
| b2da695102 | |||
| e1327a93c7 | |||
| 421bc71bb7 | |||
| 84ec139ce6 | |||
| b748b96237 | |||
| c2671ac2ae |
@@ -0,0 +1 @@
|
||||
6.2.4
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:meshcore_open/models/discovery_contact.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
@@ -22,6 +21,7 @@ import '../services/app_settings_service.dart';
|
||||
import '../services/background_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'meshcore_connector_usb.dart';
|
||||
import 'meshcore_connector_tcp.dart';
|
||||
import '../storage/channel_message_store.dart';
|
||||
import '../storage/channel_order_store.dart';
|
||||
import '../storage/channel_settings_store.dart';
|
||||
@@ -86,7 +86,7 @@ enum MeshCoreConnectionState {
|
||||
disconnecting,
|
||||
}
|
||||
|
||||
enum MeshCoreTransportType { bluetooth, usb }
|
||||
enum MeshCoreTransportType { bluetooth, usb, tcp }
|
||||
|
||||
class RepeaterBatterySnapshot {
|
||||
final int millivolts;
|
||||
@@ -116,11 +116,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
bool _manualDisconnect = false;
|
||||
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
|
||||
StreamSubscription<Uint8List>? _usbFrameSubscription;
|
||||
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
|
||||
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
|
||||
|
||||
final List<ScanResult> _scanResults = [];
|
||||
final List<Contact> _contacts = [];
|
||||
final List<DiscoveryContact> _discoveredContacts = [];
|
||||
final List<Contact> _discoveredContacts = [];
|
||||
final List<Channel> _channels = [];
|
||||
final Map<String, List<Message>> _conversations = {};
|
||||
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
||||
@@ -198,6 +199,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
int _queueSyncRetries = 0;
|
||||
static const int _maxQueueSyncRetries = 3;
|
||||
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
|
||||
// Serializes path operations (setContactPath/clearContactPath) to prevent
|
||||
// interleaved async calls from leaving in-memory state inconsistent with device.
|
||||
Future<void> _pathOpLock = Future.value();
|
||||
Map<String, String>? _currentCustomVars;
|
||||
|
||||
// Channel syncing state (sequential pattern)
|
||||
@@ -255,6 +259,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
bool get isUsbTransportConnected =>
|
||||
_state == MeshCoreConnectionState.connected &&
|
||||
_activeTransport == MeshCoreTransportType.usb;
|
||||
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
|
||||
bool get isTcpTransportConnected =>
|
||||
_state == MeshCoreConnectionState.connected &&
|
||||
_activeTransport == MeshCoreTransportType.tcp;
|
||||
|
||||
String get deviceDisplayName {
|
||||
if (_selfName != null && _selfName!.isNotEmpty) {
|
||||
@@ -281,7 +289,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveryContact> get discoveredContacts {
|
||||
List<Contact> get discoveredContacts {
|
||||
return List.unmodifiable(_discoveredContacts);
|
||||
}
|
||||
|
||||
@@ -291,6 +299,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
bool get isLoadingChannels => _isLoadingChannels;
|
||||
Stream<Uint8List> get receivedFrames => _receivedFramesController.stream;
|
||||
Uint8List? get selfPublicKey => _selfPublicKey;
|
||||
String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0));
|
||||
String? get selfName => _selfName;
|
||||
double? get selfLatitude => _selfLatitude;
|
||||
double? get selfLongitude => _selfLongitude;
|
||||
@@ -552,6 +561,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_unreadStore.saveContactUnreadCount(
|
||||
Map<String, int>.from(_contactUnreadCount),
|
||||
);
|
||||
_notificationService.clearContactNotification(
|
||||
contactKeyHex,
|
||||
getTotalUnreadCount(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -570,6 +583,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_channels.isNotEmpty ? _channels : _cachedChannels,
|
||||
),
|
||||
);
|
||||
_notificationService.clearChannelNotification(
|
||||
channelIndex,
|
||||
getTotalUnreadCount(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -659,6 +676,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_appDebugLogService = appDebugLogService;
|
||||
_backgroundService = backgroundService;
|
||||
_usbManager.setDebugLogService(_appDebugLogService);
|
||||
_tcpConnector.setDebugLogService(_appDebugLogService);
|
||||
|
||||
// Initialize notification service
|
||||
_notificationService.initialize();
|
||||
@@ -691,7 +709,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadDiscoveredContactCache() async {
|
||||
Future<void> _loadDiscoveredContactCache() async {
|
||||
final cached = await _discoveryContactStore.loadContacts();
|
||||
_discoveredContacts
|
||||
..clear()
|
||||
@@ -906,10 +924,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
try {
|
||||
await _usbFrameSubscription?.cancel();
|
||||
_usbFrameSubscription = null;
|
||||
_appDebugLogService?.info(
|
||||
'connectUsb: opening serial port…',
|
||||
tag: 'USB',
|
||||
);
|
||||
_appDebugLogService?.info('connectUsb: opening serial port…', tag: 'USB');
|
||||
await _usbManager.connect(portName: portName, baudRate: baudRate);
|
||||
_appDebugLogService?.info(
|
||||
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
|
||||
@@ -967,6 +982,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 {
|
||||
if (_state == MeshCoreConnectionState.connecting ||
|
||||
_state == MeshCoreConnectionState.connected) {
|
||||
@@ -1196,7 +1347,6 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
await _requestDeviceInfo();
|
||||
_startBatteryPolling();
|
||||
unawaited(loadDiscoveredContactCache());
|
||||
|
||||
final gotSelfInfo = await _waitForSelfInfo(
|
||||
timeout: const Duration(seconds: 3),
|
||||
@@ -1233,6 +1383,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
bool get _shouldGateInitialChannelSync =>
|
||||
_activeTransport == MeshCoreTransportType.usb ||
|
||||
_activeTransport == MeshCoreTransportType.tcp ||
|
||||
(_activeTransport == MeshCoreTransportType.bluetooth &&
|
||||
PlatformInfo.isWeb);
|
||||
|
||||
@@ -1279,9 +1430,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Future<void> disconnect({bool manual = true}) async {
|
||||
if (_state == MeshCoreConnectionState.disconnecting) return;
|
||||
final transportAtDisconnect = _activeTransport;
|
||||
final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb
|
||||
? 'USB'
|
||||
: 'BLE';
|
||||
final transportLabel = switch (transportAtDisconnect) {
|
||||
MeshCoreTransportType.bluetooth => 'BLE',
|
||||
MeshCoreTransportType.usb => 'USB',
|
||||
MeshCoreTransportType.tcp => 'TCP',
|
||||
};
|
||||
|
||||
_appDebugLogService?.info(
|
||||
'Starting disconnect transport=$transportLabel manual=$manual',
|
||||
@@ -1301,6 +1454,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await _usbFrameSubscription?.cancel();
|
||||
_usbFrameSubscription = null;
|
||||
await _usbManager.disconnect();
|
||||
await _tcpConnector.disconnect();
|
||||
|
||||
await _notifySubscription?.cancel();
|
||||
_notifySubscription = null;
|
||||
@@ -1382,6 +1536,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await _usbManager.write(data);
|
||||
} else if (_activeTransport == MeshCoreTransportType.tcp) {
|
||||
await _tcpConnector.write(data);
|
||||
} else {
|
||||
if (_rxCharacteristic == null) {
|
||||
throw Exception("MeshCore RX characteristic not available");
|
||||
@@ -1595,18 +1751,33 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Uint8List customPath,
|
||||
int pathLen,
|
||||
) async {
|
||||
if (!isConnected) return;
|
||||
// Serialize path operations to prevent interleaved async calls from
|
||||
// leaving in-memory state inconsistent with the device.
|
||||
final prev = _pathOpLock;
|
||||
final completer = Completer<void>();
|
||||
_pathOpLock = completer.future;
|
||||
await prev;
|
||||
try {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
customPath,
|
||||
pathLen,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
),
|
||||
);
|
||||
await sendFrame(
|
||||
buildUpdateContactPathFrame(
|
||||
contact.publicKey,
|
||||
customPath,
|
||||
pathLen,
|
||||
type: contact.type,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
),
|
||||
);
|
||||
// USB writes return instantly (no BLE flow control), so give the firmware
|
||||
// time to persist the path change before subsequent commands.
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
|
||||
@@ -1906,7 +2077,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Future<void> removeContact(Contact contact) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
_handleDiscovery(contact, Uint8List(0), noNotify: true);
|
||||
_handleDiscovery(
|
||||
contact,
|
||||
contact.rawPacket ?? Uint8List(0),
|
||||
noNotify: true,
|
||||
);
|
||||
|
||||
await sendFrame(buildRemoveContactFrame(contact.publicKey));
|
||||
_contacts.removeWhere((c) => c.publicKeyHex == contact.publicKeyHex);
|
||||
@@ -1922,7 +2097,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
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;
|
||||
_discoveredContacts.removeWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
@@ -1931,7 +2119,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> importDiscoveredContact(DiscoveryContact contact) async {
|
||||
Future<void> importDiscoveredContact(Contact contact) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(
|
||||
@@ -1940,11 +2128,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
contact.path,
|
||||
contact.pathLength,
|
||||
type: contact.type,
|
||||
flags: 0,
|
||||
flags: contact.flags,
|
||||
name: contact.name,
|
||||
lat: contact.latitude,
|
||||
lon: contact.longitude,
|
||||
lastModified: contact.lastSeen,
|
||||
),
|
||||
);
|
||||
|
||||
// Update the discovered contact to mark it as active (imported)
|
||||
final discoveredIndex = _discoveredContacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
if (discoveredIndex >= 0) {
|
||||
_discoveredContacts[discoveredIndex] =
|
||||
_discoveredContacts[discoveredIndex].copyWith(isActive: true);
|
||||
}
|
||||
|
||||
_handleContactAdvert(
|
||||
Contact(
|
||||
publicKey: contact.publicKey,
|
||||
@@ -1952,30 +2152,44 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
type: contact.type,
|
||||
pathLength: contact.pathLength,
|
||||
path: contact.path,
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: DateTime.now(),
|
||||
flags: contact.flags,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearContactPath(Contact contact) async {
|
||||
if (!isConnected) return;
|
||||
// Serialize path operations to prevent interleaved async calls.
|
||||
final prev = _pathOpLock;
|
||||
final completer = Completer<void>();
|
||||
_pathOpLock = completer.future;
|
||||
await prev;
|
||||
try {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||
final existingIndex = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
final existing = _contacts[existingIndex];
|
||||
// Use copyWith to preserve pathOverride and pathOverrideBytes
|
||||
_contacts[existingIndex] = existing.copyWith(
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
await sendFrame(buildResetPathFrame(contact.publicKey));
|
||||
if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
final existingIndex = _contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
if (existingIndex >= 0) {
|
||||
final existing = _contacts[existingIndex];
|
||||
// Preserve pathOverride and pathOverrideBytes — only reset device path
|
||||
_contacts[existingIndex] = existing.copyWith(
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
}
|
||||
} finally {
|
||||
completer.complete();
|
||||
}
|
||||
// The device will send updated contact info with path_len = -1
|
||||
}
|
||||
|
||||
void updateContactInMemory(
|
||||
@@ -2250,6 +2464,14 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_hasLoadedChannels = true;
|
||||
_previousChannelsCache.clear();
|
||||
}
|
||||
|
||||
// Fallback: if contact sync was deferred waiting for channel 0 but
|
||||
// channel sync finished without triggering it, start contacts now.
|
||||
if (_pendingInitialContactsSync && isConnected) {
|
||||
_pendingInitialContactsSync = false;
|
||||
unawaited(getContacts());
|
||||
}
|
||||
|
||||
// Keep cache on failure/disconnection for future attempts
|
||||
}
|
||||
|
||||
@@ -2303,10 +2525,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_isLoadingContacts = true;
|
||||
notifyListeners();
|
||||
break;
|
||||
case pushCodeAdvert:
|
||||
// Known contact was seen again - just a pub key, no action needed
|
||||
break;
|
||||
case pushCodeNewAdvert:
|
||||
debugPrint('Got New CONTACT');
|
||||
// It's the same format as respCodeContact, so we can reuse the handler
|
||||
_handleContact(frame);
|
||||
_handleContact(frame, isContact: false);
|
||||
break;
|
||||
case respCodeContact:
|
||||
debugPrint('Got CONTACT');
|
||||
@@ -2316,6 +2541,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
debugPrint('Got END_OF_CONTACTS');
|
||||
_isLoadingContacts = false;
|
||||
_preserveContactsOnRefresh = false;
|
||||
unawaited(updateKnownDiscovered());
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
if (PlatformInfo.isWeb &&
|
||||
@@ -2331,7 +2557,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
if (_pendingDeferredChannelSyncAfterContacts &&
|
||||
(_activeTransport == MeshCoreTransportType.bluetooth ||
|
||||
_activeTransport == MeshCoreTransportType.usb)) {
|
||||
_activeTransport == MeshCoreTransportType.usb ||
|
||||
_activeTransport == MeshCoreTransportType.tcp)) {
|
||||
_pendingDeferredChannelSyncAfterContacts = false;
|
||||
_pendingInitialChannelSync = false;
|
||||
unawaited(getChannels());
|
||||
@@ -2482,6 +2709,28 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
selfName.isNotEmpty) {
|
||||
_usbManager.updateConnectedLabel(selfName);
|
||||
}
|
||||
|
||||
//set all the stores' public key so they can load the correct data
|
||||
_channelMessageStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_messageStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelOrderStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_contactSettingsStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_contactStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_channelStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
|
||||
|
||||
// Now that we have self info, we can load all the persisted data for this node
|
||||
_loadChannelOrder();
|
||||
loadContactCache();
|
||||
loadChannelSettings();
|
||||
loadCachedChannels();
|
||||
|
||||
// Load persisted channel messages
|
||||
loadAllChannelMessages();
|
||||
loadUnreadState();
|
||||
_loadDiscoveredContactCache();
|
||||
|
||||
_awaitingSelfInfo = false;
|
||||
_selfInfoRetryTimer?.cancel();
|
||||
_selfInfoRetryTimer = null;
|
||||
@@ -2498,14 +2747,16 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
if (PlatformInfo.isWeb &&
|
||||
_activeTransport == MeshCoreTransportType.bluetooth) {
|
||||
_pendingInitialContactsSync = true;
|
||||
} else if (_activeTransport == MeshCoreTransportType.usb) {
|
||||
} else if (_activeTransport == MeshCoreTransportType.usb ||
|
||||
_activeTransport == MeshCoreTransportType.tcp) {
|
||||
_pendingDeferredChannelSyncAfterContacts = true;
|
||||
getContacts();
|
||||
} else {
|
||||
getContacts();
|
||||
}
|
||||
if (_shouldGateInitialChannelSync &&
|
||||
_activeTransport != MeshCoreTransportType.usb) {
|
||||
_activeTransport != MeshCoreTransportType.usb &&
|
||||
_activeTransport != MeshCoreTransportType.tcp) {
|
||||
_maybeStartInitialChannelSync();
|
||||
}
|
||||
}
|
||||
@@ -2655,7 +2906,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleContact(Uint8List frame) {
|
||||
void _handleContact(Uint8List frame, {bool isContact = true}) {
|
||||
final contact = Contact.fromFrame(frame);
|
||||
if (contact != null) {
|
||||
if (contact.type == advTypeRepeater) {
|
||||
@@ -2694,11 +2945,23 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
tag: 'Connector',
|
||||
);
|
||||
} else {
|
||||
_contacts.add(contact);
|
||||
appLogger.info(
|
||||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
||||
tag: 'Connector',
|
||||
);
|
||||
if ((_autoAddUsers && contact.type == advTypeChat) ||
|
||||
(_autoAddRepeaters && contact.type == advTypeRepeater) ||
|
||||
(_autoAddRoomServers && contact.type == advTypeRoom) ||
|
||||
(_autoAddSensors && contact.type == advTypeSensor) ||
|
||||
isContact) {
|
||||
_contacts.add(contact);
|
||||
appLogger.info(
|
||||
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
|
||||
tag: 'Connector',
|
||||
);
|
||||
} else {
|
||||
appLogger.info(
|
||||
"Discovered contact ${contact.name} (type ${contact.typeLabel}) not added due to auto-add settings",
|
||||
tag: 'Connector',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_knownContactKeys.add(contact.publicKeyHex);
|
||||
_loadMessagesForContact(contact.publicKeyHex);
|
||||
@@ -4261,6 +4524,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_batteryPollTimer?.cancel();
|
||||
_receivedFramesController.close();
|
||||
_usbManager.dispose();
|
||||
_tcpConnector.dispose();
|
||||
|
||||
// Flush pending unread writes before disposal
|
||||
_unreadStore.flush();
|
||||
@@ -4270,44 +4534,40 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
void _handleRxData(Uint8List frame) {
|
||||
final packet = BufferReader(frame);
|
||||
double snr = 0.0;
|
||||
int routeType = 0;
|
||||
int payloadType = 0;
|
||||
Uint8List pathBytes = Uint8List(0);
|
||||
Uint8List payload = Uint8List(0);
|
||||
try {
|
||||
packet.skipBytes(1); // Skip frame type byte
|
||||
snr = packet.readInt8() / 4.0;
|
||||
final snr = packet.readInt8() / 4.0;
|
||||
packet.skipBytes(1); // Skip RSSI byte
|
||||
//final rssi = packet.readByte();
|
||||
final header = packet.readByte();
|
||||
routeType = header & 0x03;
|
||||
payloadType = (header >> 2) & 0x0F;
|
||||
final routeType = header & 0x03;
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
if (routeType == _routeTransportFlood ||
|
||||
routeType == _routeTransportDirect) {
|
||||
packet.skipBytes(4); // Skip transport-specific bytes
|
||||
}
|
||||
//final payloadVer = (header >> 6) & 0x03;
|
||||
final pathLen = packet.readByte();
|
||||
pathBytes = packet.readBytes(pathLen);
|
||||
payload = packet.readBytes(packet.remaining);
|
||||
final pathBytes = packet.readBytes(pathLen);
|
||||
final payload = packet.readBytes(packet.remaining);
|
||||
|
||||
final rawPacket = frame.sublist(3);
|
||||
switch (payloadType) {
|
||||
case payloadTypeADVERT:
|
||||
_handlePayloadAdvertReceived(
|
||||
rawPacket,
|
||||
payload,
|
||||
pathBytes,
|
||||
routeType,
|
||||
snr,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
final rawPacket = frame.sublist(3);
|
||||
switch (payloadType) {
|
||||
case payloadTypeADVERT:
|
||||
_handlePayloadAdvertReceived(
|
||||
rawPacket,
|
||||
payload,
|
||||
pathBytes,
|
||||
routeType,
|
||||
snr,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void importContact(Uint8List frame) {
|
||||
@@ -4332,8 +4592,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
appLogger.warn('Malformed RX frame: $e', tag: 'Connector');
|
||||
return;
|
||||
}
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String name = '';
|
||||
Uint8List publicKey = Uint8List(0);
|
||||
int type = 0;
|
||||
@@ -4369,12 +4629,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
importDiscoveredContact(
|
||||
DiscoveryContact(
|
||||
Contact(
|
||||
rawPacket: frame,
|
||||
publicKey: publicKey,
|
||||
name: name,
|
||||
type: type,
|
||||
pathLength: pathBytes.length,
|
||||
pathLength: pathBytes.isEmpty ? -1 : pathBytes.length,
|
||||
path: Uint8List.fromList(
|
||||
pathBytes.reversed.toList(),
|
||||
), // Store path in reverse for easier use in outgoing messages
|
||||
@@ -4393,8 +4653,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
double snr,
|
||||
) {
|
||||
final advert = BufferReader(payload);
|
||||
double latitude = 0.0;
|
||||
double longitude = 0.0;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
String name = '';
|
||||
String contactKeyHex = '';
|
||||
Uint8List publicKey = Uint8List(0);
|
||||
@@ -4440,6 +4700,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
if (isNewContact) {
|
||||
final newContact = Contact(
|
||||
rawPacket: rawPacket,
|
||||
publicKey: publicKey,
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -4585,13 +4846,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
flags: 0,
|
||||
isActive: false,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistDiscoveredContacts());
|
||||
return;
|
||||
}
|
||||
|
||||
final disContact = DiscoveryContact(
|
||||
final disContact = Contact(
|
||||
rawPacket: rawPacket,
|
||||
publicKey: contact.publicKey,
|
||||
name: contact.name,
|
||||
@@ -4601,6 +4864,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
isActive: false,
|
||||
flags: 0,
|
||||
);
|
||||
_discoveredContacts.add(disContact);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,7 @@ class MeshCoreUsbManager {
|
||||
// --- Configuration ---
|
||||
Future<List<String>> listPorts() => _service.listPorts();
|
||||
|
||||
void setRequestPortLabel(String label) =>
|
||||
_service.setRequestPortLabel(label);
|
||||
void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
|
||||
|
||||
void setFallbackDeviceName(String label) =>
|
||||
_service.setFallbackDeviceName(label);
|
||||
@@ -36,7 +35,10 @@ class MeshCoreUsbManager {
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
Future<void> connect({required String portName, int baudRate = 115200}) async {
|
||||
Future<void> connect({
|
||||
required String portName,
|
||||
int baudRate = 115200,
|
||||
}) async {
|
||||
_debugLog?.info(
|
||||
'UsbManager.connect: portName=$portName baud=$baudRate',
|
||||
tag: 'USB',
|
||||
@@ -51,6 +53,9 @@ class MeshCoreUsbManager {
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!_service.isConnected && _activePortKey == null) {
|
||||
return;
|
||||
}
|
||||
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
||||
await _service.disconnect();
|
||||
_activePortKey = null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
int _lastPointer = 0;
|
||||
final Uint8List _buffer;
|
||||
|
||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||
@@ -13,6 +14,7 @@ class BufferReader {
|
||||
int readByte() => readBytes(1)[0];
|
||||
|
||||
Uint8List readBytes(int count) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
@@ -24,6 +26,7 @@ class BufferReader {
|
||||
}
|
||||
|
||||
void skipBytes(int count) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
@@ -35,6 +38,7 @@ class BufferReader {
|
||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||
|
||||
String readString() {
|
||||
_lastPointer = _pointer;
|
||||
final value = readRemainingBytes();
|
||||
try {
|
||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||
@@ -43,7 +47,8 @@ class BufferReader {
|
||||
}
|
||||
}
|
||||
|
||||
String readCString(int maxLength) {
|
||||
String readCStringGreedy(int maxLength) {
|
||||
_lastPointer = _pointer;
|
||||
final value = <int>[];
|
||||
final bytes = readBytes(maxLength);
|
||||
for (final byte in bytes) {
|
||||
@@ -57,6 +62,24 @@ class BufferReader {
|
||||
}
|
||||
}
|
||||
|
||||
String readCString(int maxLength) {
|
||||
final backupPointer = _pointer;
|
||||
final value = <int>[];
|
||||
int counter = 0;
|
||||
while (counter < maxLength) {
|
||||
final byte = readByte();
|
||||
if (byte == 0) break;
|
||||
value.add(byte);
|
||||
counter++;
|
||||
}
|
||||
_lastPointer = backupPointer;
|
||||
try {
|
||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||
} catch (e) {
|
||||
return String.fromCharCodes(value); // Latin-1 fallback
|
||||
}
|
||||
}
|
||||
|
||||
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
||||
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||
int readUInt16LE() =>
|
||||
@@ -78,6 +101,9 @@ class BufferReader {
|
||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
void resetPointer() => _pointer = 0;
|
||||
void rewind() => _pointer = _lastPointer;
|
||||
}
|
||||
|
||||
// Buffer Writer - accumulating binary data builder
|
||||
@@ -122,6 +148,19 @@ class BufferWriter {
|
||||
void writeHex(String hex) {
|
||||
writeBytes(hex2Uint8List(hex));
|
||||
}
|
||||
|
||||
void writeBytesPadded(Uint8List bytes, int totalLength) {
|
||||
// Path data (64 bytes, zero-padded)
|
||||
final bytesPadded = Uint8List(totalLength);
|
||||
final len = bytes.length < totalLength ? bytes.length : totalLength;
|
||||
if (bytes.isNotEmpty && len > 0) {
|
||||
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
|
||||
for (int i = 0; i < copyLen; i++) {
|
||||
bytesPadded[i] = bytes[i];
|
||||
}
|
||||
}
|
||||
writeBytes(bytesPadded);
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List hex2Uint8List(String hex) {
|
||||
@@ -650,14 +689,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
|
||||
}
|
||||
|
||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||||
Uint8List buildUpdateContactPathFrame(
|
||||
Uint8List pubKey,
|
||||
Uint8List customPath,
|
||||
Uint8List path,
|
||||
int pathLen, {
|
||||
int type = 1, // ADV_TYPE_CHAT
|
||||
int flags = 0,
|
||||
String name = '',
|
||||
double? lat,
|
||||
double? lon,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAddUpdateContact);
|
||||
@@ -666,17 +708,7 @@ Uint8List buildUpdateContactPathFrame(
|
||||
writer.writeByte(flags);
|
||||
writer.writeByte(pathLen);
|
||||
|
||||
// Path data (64 bytes, zero-padded)
|
||||
final pathPadded = Uint8List(maxPathSize);
|
||||
if (customPath.isNotEmpty && pathLen > 0) {
|
||||
final copyLen = customPath.length < maxPathSize
|
||||
? customPath.length
|
||||
: maxPathSize;
|
||||
for (int i = 0; i < copyLen; i++) {
|
||||
pathPadded[i] = customPath[i];
|
||||
}
|
||||
}
|
||||
writer.writeBytes(pathPadded);
|
||||
writer.writeBytesPadded(path, maxPathSize);
|
||||
|
||||
// Name (32 bytes, null-padded)
|
||||
writer.writeCString(name, maxNameSize);
|
||||
@@ -685,6 +717,27 @@ Uint8List buildUpdateContactPathFrame(
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(timestamp);
|
||||
|
||||
if ((lat == null || lon == null) && lastModified != null) {
|
||||
// If lat/lon not provided, write zeros
|
||||
writer.writeInt32LE(0);
|
||||
writer.writeInt32LE(0);
|
||||
} else {
|
||||
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||||
// Latitude
|
||||
final latitude = lat ?? 0.0;
|
||||
writer.writeInt32LE((latitude * 1e6).round());
|
||||
|
||||
// Longitude
|
||||
final longitude = lon ?? 0.0;
|
||||
writer.writeInt32LE((longitude * 1e6).round());
|
||||
}
|
||||
|
||||
if (lastModified != null) {
|
||||
// Last modified
|
||||
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(lastModifiedTimestamp);
|
||||
}
|
||||
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -1859,5 +1859,34 @@
|
||||
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
|
||||
"usbStatus_notConnected": "Изберете USB устройство",
|
||||
"usbStatus_searching": "Търсене на USB устройства...",
|
||||
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка."
|
||||
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Свържете се чрез TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostLabel": "IP адрес",
|
||||
"tcpPortLabel": "Пристанище",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
|
||||
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
|
||||
"tcpErrorHostRequired": "Необходим е IP адрес.",
|
||||
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
|
||||
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение"
|
||||
}
|
||||
|
||||
+30
-1
@@ -1887,5 +1887,34 @@
|
||||
"usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus",
|
||||
"usbStatus_connecting": "Verbindung zum USB-Gerät...",
|
||||
"usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}",
|
||||
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält."
|
||||
"usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "IP-Adresse",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Verbinden über TCP",
|
||||
"tcpPortLabel": "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
@@ -49,6 +49,33 @@
|
||||
"scanner_title": "MeshCore Open",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Connect over TCP",
|
||||
"tcpHostLabel": "IP Address",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Enter endpoint and connect",
|
||||
"tcpStatus_connectingTo": "Connecting to {endpoint}...",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpErrorHostRequired": "IP address is required.",
|
||||
"tcpErrorPortInvalid": "Port must be between 1 and 65535.",
|
||||
"tcpErrorUnsupported": "TCP transport is not supported on this platform.",
|
||||
"tcpErrorTimedOut": "TCP connection timed out.",
|
||||
"tcpConnectionFailed": "TCP connection failed: {error}",
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbScreenTitle": "Connect over USB",
|
||||
"usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.",
|
||||
"usbScreenStatus": "Select a USB device",
|
||||
@@ -780,6 +807,7 @@
|
||||
"map_source": "Source",
|
||||
"map_flags": "Flags",
|
||||
"map_shareMarkerHere": "Share marker here",
|
||||
"map_setAsMyLocation": "Set as my location",
|
||||
"map_pinLabel": "Pin label",
|
||||
"map_label": "Label",
|
||||
"map_pointOfInterest": "Point of interest",
|
||||
@@ -807,6 +835,7 @@
|
||||
"map_markers": "Markers",
|
||||
"map_showSharedMarkers": "Show shared markers",
|
||||
"map_showGuessedLocations": "Show guessed node locations",
|
||||
"map_showDiscoveryContacts": "Show Discovery Contacts",
|
||||
"map_guessedLocation": "Guessed location",
|
||||
"map_lastSeenTime": "Last Seen Time",
|
||||
"map_sharedPin": "Shared pin",
|
||||
@@ -1898,4 +1927,4 @@
|
||||
"discoveredContacts_deleteContact": "Delete Discovered Contact",
|
||||
"discoveredContacts_deleteContactAll": "Delete All Discovered Contacts",
|
||||
"discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -1887,5 +1887,34 @@
|
||||
"usbStatus_searching": "Buscando dispositivos USB...",
|
||||
"usbStatus_notConnected": "Seleccione un dispositivo USB",
|
||||
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
|
||||
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpScreenTitle": "Establecer conexión a través de TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "Dirección IP",
|
||||
"tcpPortLabel": "Puerto",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
|
||||
"tcpStatus_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
@@ -1859,5 +1859,34 @@
|
||||
"usbConnectionFailed": "Échec de la connexion USB : {error}",
|
||||
"usbStatus_connecting": "Connexion au périphérique USB...",
|
||||
"usbStatus_searching": "Recherche de périphériques USB...",
|
||||
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "Adresse IP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Établir une connexion via TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.",
|
||||
"tcpStatus_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
@@ -1859,5 +1859,34 @@
|
||||
"usbConnectionFailed": "Errore nella connessione USB: {error}",
|
||||
"usbStatus_notConnected": "Seleziona un dispositivo USB",
|
||||
"usbStatus_connecting": "Connessione al dispositivo USB...",
|
||||
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "Indirizzo IP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
|
||||
"tcpPortLabel": "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"
|
||||
}
|
||||
|
||||
@@ -334,6 +334,84 @@ abstract class AppLocalizations {
|
||||
/// **'Bluetooth'**
|
||||
String get connectionChoiceBluetoothLabel;
|
||||
|
||||
/// No description provided for @connectionChoiceTcpLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TCP'**
|
||||
String get connectionChoiceTcpLabel;
|
||||
|
||||
/// No description provided for @tcpScreenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connect over TCP'**
|
||||
String get tcpScreenTitle;
|
||||
|
||||
/// No description provided for @tcpHostLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'IP Address'**
|
||||
String get tcpHostLabel;
|
||||
|
||||
/// No description provided for @tcpHostHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'192.168.40.10'**
|
||||
String get tcpHostHint;
|
||||
|
||||
/// No description provided for @tcpPortLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Port'**
|
||||
String get tcpPortLabel;
|
||||
|
||||
/// No description provided for @tcpPortHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'5000'**
|
||||
String get tcpPortHint;
|
||||
|
||||
/// No description provided for @tcpStatus_notConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter endpoint and connect'**
|
||||
String get tcpStatus_notConnected;
|
||||
|
||||
/// No description provided for @tcpStatus_connectingTo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connecting to {endpoint}...'**
|
||||
String tcpStatus_connectingTo(String endpoint);
|
||||
|
||||
/// No description provided for @tcpErrorHostRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'IP address is required.'**
|
||||
String get tcpErrorHostRequired;
|
||||
|
||||
/// No description provided for @tcpErrorPortInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Port must be between 1 and 65535.'**
|
||||
String get tcpErrorPortInvalid;
|
||||
|
||||
/// No description provided for @tcpErrorUnsupported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TCP transport is not supported on this platform.'**
|
||||
String get tcpErrorUnsupported;
|
||||
|
||||
/// No description provided for @tcpErrorTimedOut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TCP connection timed out.'**
|
||||
String get tcpErrorTimedOut;
|
||||
|
||||
/// No description provided for @tcpConnectionFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'TCP connection failed: {error}'**
|
||||
String tcpConnectionFailed(String error);
|
||||
|
||||
/// No description provided for @usbScreenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2668,6 +2746,12 @@ abstract class AppLocalizations {
|
||||
/// **'Share marker here'**
|
||||
String get map_shareMarkerHere;
|
||||
|
||||
/// No description provided for @map_setAsMyLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set as my location'**
|
||||
String get map_setAsMyLocation;
|
||||
|
||||
/// No description provided for @map_pinLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2788,6 +2872,12 @@ abstract class AppLocalizations {
|
||||
/// **'Show guessed node locations'**
|
||||
String get map_showGuessedLocations;
|
||||
|
||||
/// No description provided for @map_showDiscoveryContacts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show Discovery Contacts'**
|
||||
String get map_showDiscoveryContacts;
|
||||
|
||||
/// No description provided for @map_guessedLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Свържете се чрез TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP адрес';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Пристанище';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Свързване към $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Необходим е IP адрес.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'Транспортът чрез TCP не се поддържа на тази платформа.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'Връзката TCP изтекла.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Неуспешно е установено TCP връзката: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Свържете се чрез USB';
|
||||
|
||||
@@ -1467,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Споделете маркер тук';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Задайте като моя местоположение';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Етикетиране на пин';
|
||||
|
||||
@@ -1531,6 +1578,9 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Покажете местоположенията на предположените възли.';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Покажи контакти за откриване';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Предполагано местоположение';
|
||||
|
||||
|
||||
@@ -117,6 +117,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Verbinden über TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP-Adresse';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Verbinden über USB';
|
||||
|
||||
@@ -1467,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Pin Name';
|
||||
|
||||
@@ -1531,6 +1580,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Zeige die vermuteten Knotenpositionen';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Entdeckungs-Kontakte anzeigen';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Geschätzter Ort';
|
||||
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Connect over TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP Address';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Port';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Enter endpoint and connect';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Connecting to $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'IP address is required.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'TCP transport is not supported on this platform.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'TCP connection timed out.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'TCP connection failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Connect over USB';
|
||||
|
||||
@@ -1443,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Share marker here';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Set as my location';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Pin label';
|
||||
|
||||
@@ -1506,6 +1553,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get map_showGuessedLocations => 'Show guessed node locations';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Show Discovery Contacts';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Guessed location';
|
||||
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Establecer conexión a través de TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'Dirección IP';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Puerto';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Conectándose a $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Se requiere la dirección IP.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'El protocolo de transporte TCP no está soportado en esta plataforma.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'La conexión TCP ha caducado.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Error en la conexión TCP: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Conecte mediante USB';
|
||||
|
||||
@@ -1465,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Compartir marcador aquí';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Establecer mi ubicación';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Etiqueta de marcador';
|
||||
|
||||
@@ -1529,6 +1576,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Mostrar las ubicaciones estimadas de los nodos.';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Mostrar Contactos de Descubrimiento';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Ubicación estimada';
|
||||
|
||||
|
||||
@@ -117,6 +117,52 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Établir une connexion via TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'Adresse IP';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Port';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected =>
|
||||
'Entrez l\'adresse de destination et connectez-vous.';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Connexion à $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid =>
|
||||
'La taille du port doit être comprise entre 1 et 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'Le protocole TCP n\'est pas pris en charge sur cette plateforme.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'La connexion TCP a expiré.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Échec de la connexion TCP : $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Connectez via USB';
|
||||
|
||||
@@ -1472,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Partager le marqueur ici';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Définir comme ma localisation';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Étiquete de repin';
|
||||
|
||||
@@ -1536,6 +1585,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Afficher les emplacements des nœuds estimés';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Afficher les contacts de découverte';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Lieu deviné';
|
||||
|
||||
|
||||
@@ -117,6 +117,51 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Stabilire una connessione tramite TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'Indirizzo IP';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Connessione tramite USB';
|
||||
|
||||
@@ -1465,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Condividi marcatore qui';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Imposta come la mia posizione';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Etichetta PIN';
|
||||
|
||||
@@ -1528,6 +1576,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get map_showGuessedLocations => 'Mostra le posizioni stimate dei nodi';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Mostra Contatti di Discovery';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Località indovinata';
|
||||
|
||||
|
||||
@@ -117,6 +117,51 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Verbind via TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP-adres';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Verbind via USB';
|
||||
|
||||
@@ -1457,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Deel marker hier';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Label vastzetten';
|
||||
|
||||
@@ -1521,6 +1569,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Toon de voorspelde locaties van de knopen';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Ontdek contacten weergeven';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Geroerde locatie';
|
||||
|
||||
|
||||
@@ -117,6 +117,52 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'Adres IP';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Port';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Połączenie z $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Wymagana jest adresa IP.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid =>
|
||||
'Numer portu musi mieścić się w zakresie od 1 do 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut =>
|
||||
'Połączenie TCP zakończyło się bez powodzenia.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Błąd połączenia TCP: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Połącz przez USB';
|
||||
|
||||
@@ -1466,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Oznacz etykietę';
|
||||
|
||||
@@ -1530,6 +1579,9 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Wyświetl lokalizacje zgadanych węzłów';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Pokaż kontakty odkrywania';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Wydana lokalizacja';
|
||||
|
||||
|
||||
@@ -117,6 +117,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Estabelecer conexão via TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'Endereço IP';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Conecte via USB';
|
||||
|
||||
@@ -1466,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Compartilhar marcador aqui';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Defina minha localização';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Rótulo de marcador';
|
||||
|
||||
@@ -1530,6 +1578,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Mostrar as localizações dos nós estimados';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Mostrar Contatos de Descoberta';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Localização estimada';
|
||||
|
||||
|
||||
@@ -117,6 +117,51 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Установить соединение по протоколу TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP-адрес';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Порт';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Введите адрес и подключитесь.';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Подключение к $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid =>
|
||||
'Порт должен находиться в диапазоне от 1 до 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'Протокол TCP не поддерживается на этой платформе.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Не удалось установить соединение TCP: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Подключение через USB';
|
||||
|
||||
@@ -1468,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Поделиться меткой здесь';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Установить мое местоположение';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Метка';
|
||||
|
||||
@@ -1532,6 +1580,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Отобразить предполагаемые места расположения узлов';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Показать контакты Discovery';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Угаданное место';
|
||||
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP adresa';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Pripojte cez USB';
|
||||
|
||||
@@ -1460,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Zdieľte značku tu';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Označka upozornenia';
|
||||
|
||||
@@ -1524,6 +1571,9 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Zobraziť umiestnenia odhadnutých uzlov';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Zobraziť kontakty objavov';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Odhadnutá lokalita';
|
||||
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Komunicirajte preko protokola TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP naslov';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Povežite preko USB';
|
||||
|
||||
@@ -1454,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Delite točke tukaj.';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Oznaka za pritrditev';
|
||||
|
||||
@@ -1517,6 +1564,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get map_showGuessedLocations => 'Pokaži lokacije domnevnih not.';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Prikaži odkritja kontaktov';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Predpostavljena lokacija';
|
||||
|
||||
|
||||
@@ -117,6 +117,50 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'Anslut via TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP-adress';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '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
|
||||
String get usbScreenTitle => 'Anslut via USB';
|
||||
|
||||
@@ -1450,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Dela markeringen här';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Ange som min plats';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Fästetikett';
|
||||
|
||||
@@ -1514,6 +1561,9 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Visa upp de antagna nodernas placeringar';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Visa Discovery-kontakter';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Gissad plats';
|
||||
|
||||
|
||||
@@ -117,6 +117,51 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => 'Bluetooth';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => 'З\'єднатися через протокол TCP';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP-адреса';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => 'Порт';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return 'Підключення до $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported =>
|
||||
'Транспорт TCP не підтримується на цій платформі.';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut =>
|
||||
'З\'єднання TCP завершилося через закінчення часу очікування.';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'Не вдалося встановити з\'єднання TCP: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => 'Підключити через USB';
|
||||
|
||||
@@ -1465,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => 'Поділитися маркером тут';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => 'Мітка піна';
|
||||
|
||||
@@ -1529,6 +1577,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get map_showGuessedLocations =>
|
||||
'Показати місцезнаходження передбачених вузлів';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => 'Показати контакти Відкриття';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => 'Визначено місцезнаходження';
|
||||
|
||||
|
||||
@@ -117,6 +117,49 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get connectionChoiceBluetoothLabel => '蓝牙';
|
||||
|
||||
@override
|
||||
String get connectionChoiceTcpLabel => 'TCP';
|
||||
|
||||
@override
|
||||
String get tcpScreenTitle => '通过 TCP 连接';
|
||||
|
||||
@override
|
||||
String get tcpHostLabel => 'IP地址';
|
||||
|
||||
@override
|
||||
String get tcpHostHint => '192.168.40.10';
|
||||
|
||||
@override
|
||||
String get tcpPortLabel => '端口';
|
||||
|
||||
@override
|
||||
String get tcpPortHint => '5000';
|
||||
|
||||
@override
|
||||
String get tcpStatus_notConnected => '输入目标地址,然后连接';
|
||||
|
||||
@override
|
||||
String tcpStatus_connectingTo(String endpoint) {
|
||||
return '连接到 $endpoint...';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tcpErrorHostRequired => '需要提供IP地址。';
|
||||
|
||||
@override
|
||||
String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。';
|
||||
|
||||
@override
|
||||
String get tcpErrorUnsupported => '此平台不支持 TCP 传输。';
|
||||
|
||||
@override
|
||||
String get tcpErrorTimedOut => 'TCP 连接超时。';
|
||||
|
||||
@override
|
||||
String tcpConnectionFailed(String error) {
|
||||
return 'TCP 连接失败:$error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get usbScreenTitle => '通过USB连接';
|
||||
|
||||
@@ -1378,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_shareMarkerHere => '在此分享标记';
|
||||
|
||||
@override
|
||||
String get map_setAsMyLocation => '设置为我的位置';
|
||||
|
||||
@override
|
||||
String get map_pinLabel => '标签';
|
||||
|
||||
@@ -1440,6 +1486,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get map_showGuessedLocations => '显示猜测的节点位置';
|
||||
|
||||
@override
|
||||
String get map_showDiscoveryContacts => '显示发现联系人';
|
||||
|
||||
@override
|
||||
String get map_guessedLocation => '猜测的位置';
|
||||
|
||||
|
||||
+30
-1
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_notConnected": "Selecteer een USB-apparaat",
|
||||
"usbStatus_connecting": "Verbinding maken met USB-apparaat...",
|
||||
"usbStatus_searching": "Zoeken naar USB-apparaten...",
|
||||
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft."
|
||||
"usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpScreenTitle": "Verbind via TCP",
|
||||
"tcpHostLabel": "IP-adres",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "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
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_connecting": "Połączenie z urządzeniem USB...",
|
||||
"usbStatus_notConnected": "Wybierz urządzenie USB",
|
||||
"usbConnectionFailed": "Błąd połączenia USB: {error}",
|
||||
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"."
|
||||
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
|
||||
"tcpHostLabel": "Adres IP",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
|
||||
"tcpStatus_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
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_notConnected": "Selecione um dispositivo USB",
|
||||
"usbConnectionFailed": "Falha na conexão USB: {error}",
|
||||
"usbStatus_connecting": "Conectando ao dispositivo USB...",
|
||||
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "Endereço IP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Estabelecer conexão via TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpPortLabel": "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
@@ -1099,5 +1099,34 @@
|
||||
"usbStatus_connecting": "Подключение к USB-устройству...",
|
||||
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
|
||||
"usbStatus_notConnected": "Выберите USB-устройство",
|
||||
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion."
|
||||
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostLabel": "IP-адрес",
|
||||
"tcpScreenTitle": "Установить соединение по протоколу TCP",
|
||||
"tcpPortLabel": "Порт",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Введите адрес и подключитесь.",
|
||||
"tcpStatus_connectingTo": "Подключение к {endpoint}...",
|
||||
"tcpErrorHostRequired": "Необходимо указать IP-адрес.",
|
||||
"tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.",
|
||||
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
|
||||
"tcpErrorTimedOut": "Соединение TCP не удалось установить.",
|
||||
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показать контакты Discovery",
|
||||
"map_setAsMyLocation": "Установить мое местоположение"
|
||||
}
|
||||
|
||||
+30
-1
@@ -1859,5 +1859,34 @@
|
||||
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
|
||||
"usbStatus_notConnected": "Vyberte USB zariadenie",
|
||||
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
|
||||
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "IP adresa",
|
||||
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "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
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_connecting": "Povezava z USB napravo...",
|
||||
"usbStatus_searching": "Iskanje USB naprav...",
|
||||
"usbConnectionFailed": "Napaka pri povezavi preko USB: {error}",
|
||||
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion."
|
||||
"usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostLabel": "IP naslov",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Komunicirajte preko protokola TCP",
|
||||
"tcpPortLabel": "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
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_notConnected": "Välj en USB-enhet",
|
||||
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
|
||||
"usbStatus_searching": "Söker efter USB-enheter...",
|
||||
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware."
|
||||
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "IP-adress",
|
||||
"tcpScreenTitle": "Anslut via TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "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
@@ -1859,5 +1859,34 @@
|
||||
"usbStatus_notConnected": "Виберіть пристрій USB",
|
||||
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
|
||||
"usbStatus_connecting": "Підключення до USB-пристрою...",
|
||||
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion."
|
||||
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "IP-адреса",
|
||||
"tcpScreenTitle": "З'єднатися через протокол TCP",
|
||||
"tcpPortLabel": "Порт",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Введіть кінцеву точку та підключіться",
|
||||
"tcpStatus_connectingTo": "Підключення до {endpoint}...",
|
||||
"tcpErrorHostRequired": "Необхідно вказати IP-адресу.",
|
||||
"tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.",
|
||||
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
|
||||
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
|
||||
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Показати контакти Відкриття",
|
||||
"map_setAsMyLocation": "Встановити моє місцезнаходження"
|
||||
}
|
||||
|
||||
+30
-1
@@ -1864,5 +1864,34 @@
|
||||
"usbStatus_connecting": "连接USB设备...",
|
||||
"usbStatus_notConnected": "选择一个 USB 设备",
|
||||
"usbConnectionFailed": "USB 连接失败:{error}",
|
||||
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。"
|
||||
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "IP地址",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "通过 TCP 连接",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "端口",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "输入目标地址,然后连接",
|
||||
"tcpStatus_connectingTo": "连接到 {endpoint}...",
|
||||
"tcpErrorHostRequired": "需要提供IP地址。",
|
||||
"tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。",
|
||||
"tcpErrorUnsupported": "此平台不支持 TCP 传输。",
|
||||
"tcpErrorTimedOut": "TCP 连接超时。",
|
||||
"tcpConnectionFailed": "TCP 连接失败:{error}",
|
||||
"map_showDiscoveryContacts": "显示发现联系人",
|
||||
"map_setAsMyLocation": "设置为我的位置"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class AppSettings {
|
||||
final Map<String, String> batteryChemistryByRepeaterId;
|
||||
final UnitSystem unitSystem;
|
||||
final Set<String> mutedChannels;
|
||||
final bool mapShowDiscoveryContacts;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
@@ -66,6 +67,7 @@ class AppSettings {
|
||||
Map<String, String>? batteryChemistryByRepeaterId,
|
||||
this.unitSystem = UnitSystem.metric,
|
||||
Set<String>? mutedChannels,
|
||||
this.mapShowDiscoveryContacts = true,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||
mutedChannels = mutedChannels ?? {};
|
||||
@@ -97,6 +99,7 @@ class AppSettings {
|
||||
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
||||
'unit_system': unitSystem.value,
|
||||
'muted_channels': mutedChannels.toList(),
|
||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +155,8 @@ class AppSettings {
|
||||
?.map((e) => e.toString())
|
||||
.toSet()) ??
|
||||
{},
|
||||
mapShowDiscoveryContacts:
|
||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +186,7 @@ class AppSettings {
|
||||
Map<String, String>? batteryChemistryByRepeaterId,
|
||||
UnitSystem? unitSystem,
|
||||
Set<String>? mutedChannels,
|
||||
bool? mapShowDiscoveryContacts,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
@@ -217,6 +223,8 @@ class AppSettings {
|
||||
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
||||
unitSystem: unitSystem ?? this.unitSystem,
|
||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||
mapShowDiscoveryContacts:
|
||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-19
@@ -1,4 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Contact {
|
||||
@@ -15,6 +17,8 @@ class Contact {
|
||||
final double? longitude;
|
||||
final DateTime lastSeen;
|
||||
final DateTime lastMessageAt;
|
||||
final bool isActive;
|
||||
final Uint8List? rawPacket;
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
@@ -29,6 +33,8 @@ class Contact {
|
||||
this.longitude,
|
||||
required this.lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
this.isActive = true,
|
||||
this.rawPacket,
|
||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||
|
||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||
@@ -76,6 +82,8 @@ class Contact {
|
||||
double? longitude,
|
||||
DateTime? lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
bool? isActive,
|
||||
Uint8List? rawPacket,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
@@ -94,6 +102,8 @@ class Contact {
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
rawPacket: rawPacket ?? this.rawPacket,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,28 +176,27 @@ class Contact {
|
||||
|
||||
static Contact? fromFrame(Uint8List data) {
|
||||
if (data.isEmpty) return null;
|
||||
if (data[0] != respCodeContact) return null;
|
||||
final reader = BufferReader(data);
|
||||
try {
|
||||
final pubKey = Uint8List.fromList(
|
||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
||||
);
|
||||
final type = data[contactTypeOffset];
|
||||
final flags = data[contactFlagsOffset];
|
||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
||||
final respCode = reader.readByte();
|
||||
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
||||
return null;
|
||||
}
|
||||
final pubKey = reader.readBytes(pubKeySize);
|
||||
final type = reader.readByte();
|
||||
final flags = reader.readByte();
|
||||
final pathLen = reader.readByte();
|
||||
final safePathLen = pathLen > 0
|
||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||
: 0;
|
||||
final pathBytes = safePathLen > 0
|
||||
? Uint8List.fromList(
|
||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
||||
)
|
||||
: Uint8List(0);
|
||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
||||
final lastmod = readUint32LE(data, contactLastModOffset);
|
||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||
final name = reader.readCStringGreedy(maxNameSize);
|
||||
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = readInt32LE(data, contactLatOffset);
|
||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
@@ -198,14 +207,16 @@ class Contact {
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
flags: flags,
|
||||
pathLength: pathLen,
|
||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
|
||||
isActive: true,
|
||||
rawPacket: null,
|
||||
);
|
||||
} catch (e) {
|
||||
// If parsing fails, return null
|
||||
appLogger.error('Failed to parse contact frame: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class DiscoveryContact {
|
||||
final Uint8List rawPacket;
|
||||
final Uint8List publicKey;
|
||||
final String name;
|
||||
final int type;
|
||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
||||
final Uint8List path; // Path bytes from device
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime lastSeen;
|
||||
|
||||
DiscoveryContact({
|
||||
required this.rawPacket,
|
||||
required this.publicKey,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.pathLength,
|
||||
required this.path,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.lastSeen,
|
||||
});
|
||||
|
||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return 'Chat';
|
||||
case advTypeRepeater:
|
||||
return 'Repeater';
|
||||
case advTypeRoom:
|
||||
return 'Room';
|
||||
case advTypeSensor:
|
||||
return 'Sensor';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
String get pathLabel {
|
||||
if (pathLength < 0) return 'Flood';
|
||||
if (pathLength == 0) return 'Direct';
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
bool get hasLocation => latitude != null && longitude != null;
|
||||
|
||||
DiscoveryContact copyWith({
|
||||
Uint8List? rawPacket,
|
||||
Uint8List? publicKey,
|
||||
String? name,
|
||||
int? type,
|
||||
int? pathLength,
|
||||
Uint8List? path,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
DateTime? lastSeen,
|
||||
}) {
|
||||
return DiscoveryContact(
|
||||
rawPacket: rawPacket ?? this.rawPacket,
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
path: path ?? this.path,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
);
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
final pathBytes = path;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final parts = <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;
|
||||
}
|
||||
@@ -118,6 +118,19 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
onLongPress: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: entry.payload
|
||||
.map(
|
||||
(b) => b
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0'),
|
||||
)
|
||||
.join(''),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
@@ -364,11 +367,11 @@ class _ChannelMessagePathMapScreenState
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(
|
||||
selectedPath,
|
||||
connector.contacts,
|
||||
context.l10n,
|
||||
);
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
// Cache of PSK hex -> Community for quick lookup
|
||||
final Map<String, Community> _pskToCommunity = {};
|
||||
|
||||
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -61,6 +63,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadCommunities() async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
final communities = await _communityStore.loadCommunities();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -106,7 +110,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
final channelMessageStore = ChannelMessageStore();
|
||||
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
|
||||
// Auto-navigate back to scanner if disconnected
|
||||
if (!checkConnectionAndNavigate(connector)) {
|
||||
@@ -712,6 +718,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
bool isRegularHashtag = true;
|
||||
Community? selectedCommunity;
|
||||
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
@@ -763,7 +771,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget? buildExpandedContent() {
|
||||
Widget? buildExpandedContent(
|
||||
ChannelMessageStore channelMessageStore,
|
||||
) {
|
||||
switch (selectedOption) {
|
||||
case 0: // Create Private Channel
|
||||
return Column(
|
||||
@@ -788,7 +798,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
@@ -810,7 +820,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
psk[i] = random.nextInt(256);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
connector.setChannel(nextIndex, name, psk);
|
||||
await connector.setChannel(
|
||||
nextIndex,
|
||||
name,
|
||||
psk,
|
||||
);
|
||||
await channelMessageStore.clearChannelMessages(
|
||||
nextIndex,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -1329,7 +1346,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
subtitle:
|
||||
dialogContext.l10n.channels_createPrivateChannelDesc,
|
||||
),
|
||||
if (selectedOption == 0) buildExpandedContent()!,
|
||||
if (selectedOption == 0)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 1,
|
||||
@@ -1338,7 +1356,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
subtitle:
|
||||
dialogContext.l10n.channels_joinPrivateChannelDesc,
|
||||
),
|
||||
if (selectedOption == 1) buildExpandedContent()!,
|
||||
if (selectedOption == 1)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
if (!hasPublicChannel) ...[
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
@@ -1348,7 +1367,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
subtitle:
|
||||
dialogContext.l10n.channels_joinPublicChannelDesc,
|
||||
),
|
||||
if (selectedOption == 2) buildExpandedContent()!,
|
||||
if (selectedOption == 2)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
],
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
@@ -1358,7 +1378,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
subtitle:
|
||||
dialogContext.l10n.channels_joinHashtagChannelDesc,
|
||||
),
|
||||
if (selectedOption == 3) buildExpandedContent()!,
|
||||
if (selectedOption == 3)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 4,
|
||||
@@ -1366,7 +1387,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
title: dialogContext.l10n.community_scanQr,
|
||||
subtitle: dialogContext.l10n.community_join,
|
||||
),
|
||||
if (selectedOption == 4) buildExpandedContent()!,
|
||||
if (selectedOption == 4)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
const Divider(height: 1),
|
||||
buildOptionTile(
|
||||
optionIndex: 5,
|
||||
@@ -1374,7 +1396,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
title: dialogContext.l10n.community_create,
|
||||
subtitle: dialogContext.l10n.community_createDesc,
|
||||
),
|
||||
if (selectedOption == 5) buildExpandedContent()!,
|
||||
if (selectedOption == 5)
|
||||
buildExpandedContent(_channelMessageStore)!,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1524,7 +1547,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
try {
|
||||
await connector.deleteChannel(channel.index);
|
||||
|
||||
channelMessageStore.clearChannelMessages(channel.index);
|
||||
await channelMessageStore.clearChannelMessages(channel.index);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
@@ -1749,6 +1772,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
}
|
||||
|
||||
final channelCount = communityChannels.length;
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
||||
final pathLabel = _currentPathLabel(contact);
|
||||
|
||||
// Show path details if we have path data (from device or override)
|
||||
final hasPathData =
|
||||
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
|
||||
// Show path details if we have non-empty path data (from device or override)
|
||||
final effectivePath = contact.pathOverrideBytes ?? contact.path;
|
||||
final hasPathData = effectivePath.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final contact = _resolveContact(connector);
|
||||
final isFloodMode = contact.pathOverride == -1;
|
||||
|
||||
final isDirectMode = contact.pathOverride == 0;
|
||||
final activeMode = isFloodMode
|
||||
? 'flood'
|
||||
: isDirectMode
|
||||
? 'direct'
|
||||
: 'auto';
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: context.l10n.chat_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(contact, pathLen: -1);
|
||||
} else if (mode == 'direct') {
|
||||
await connector.setPathOverride(
|
||||
contact,
|
||||
pathLen: 0,
|
||||
pathBytes: Uint8List(0),
|
||||
);
|
||||
} else {
|
||||
await connector.setPathOverride(contact, pathLen: null);
|
||||
}
|
||||
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
color: activeMode == 'auto'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Text(
|
||||
context.l10n.chat_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
fontWeight: activeMode == 'auto'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'direct',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.near_me,
|
||||
size: 20,
|
||||
color: activeMode == 'direct'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.chat_direct,
|
||||
style: TextStyle(
|
||||
fontWeight: activeMode == 'direct'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
color: activeMode == 'flood'
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Text(
|
||||
context.l10n.chat_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
fontWeight: activeMode == 'flood'
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chat_sendMessageTo(widget.contact.name),
|
||||
context.l10n.chat_sendMessageTo(
|
||||
_resolveContact(context.read<MeshCoreConnector>()).name,
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
// Auto-scroll to bottom if user is already at bottom
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = widget.contact;
|
||||
Contact contact = _resolveContact(connector);
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
if (contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
senderName: widget.contact.type == advTypeRoom
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
: contact.name,
|
||||
isRoomServer: widget.contact.type == advTypeRoom,
|
||||
isRoomServer: resolvedContact.type == advTypeRoom,
|
||||
textScale: textScale,
|
||||
onTap: () => _openMessagePath(message, contact),
|
||||
onLongPress: () => _showMessageActions(message, contact),
|
||||
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
connector.sendMessage(widget.contact, text);
|
||||
connector.sendMessage(_resolveContact(connector), text);
|
||||
_textController.clear();
|
||||
_textFieldFocusNode.requestFocus();
|
||||
}
|
||||
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
// Set the path override to persist user's choice
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
Navigator.pop(context);
|
||||
await _notifyPathSet(
|
||||
connector,
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathBytes,
|
||||
path.hopCount,
|
||||
);
|
||||
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(widget.contact);
|
||||
await connector.clearContactPath(
|
||||
_resolveContact(connector),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
appLogger.info(
|
||||
'PathSelectionDialog was cancelled or returned null',
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
return;
|
||||
return; // Cancelled — keep existing path
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
tag: 'ChatScreen',
|
||||
);
|
||||
await connector.setPathOverride(
|
||||
widget.contact,
|
||||
_resolveContact(connector),
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
|
||||
|
||||
if (!mounted) return;
|
||||
await _notifyPathSet(connector, widget.contact, result, result.length);
|
||||
await _notifyPathSet(
|
||||
connector,
|
||||
_resolveContact(connector),
|
||||
result,
|
||||
result.length,
|
||||
);
|
||||
}
|
||||
|
||||
void _openMessagePath(Message message, Contact contact) {
|
||||
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final String senderName;
|
||||
if (message.isOutgoing) {
|
||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||
} else if (widget.contact.type == advTypeRoom) {
|
||||
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||
senderName = "${contact.name} [$fourByteHex]";
|
||||
} else {
|
||||
senderName = widget.contact.name;
|
||||
senderName = _resolveContact(connector).name;
|
||||
}
|
||||
final pathMessage = ChannelMessage(
|
||||
senderKey: null,
|
||||
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_retryMessage(message);
|
||||
},
|
||||
),
|
||||
if (widget.contact.type == advTypeRoom)
|
||||
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
||||
advTypeRoom)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
void _retryMessage(Message message) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
// Retry using the contact's current path override setting
|
||||
connector.sendMessage(widget.contact, message.text);
|
||||
connector.sendMessage(_resolveContact(connector), message.text);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
|
||||
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
// For room servers, include sender name (like channels) since multiple users
|
||||
// For 1:1 chats, sender is implicit (null)
|
||||
final senderName = widget.contact.type == advTypeRoom
|
||||
final liveContact = _resolveContact(connector);
|
||||
final senderName = liveContact.type == advTypeRoom
|
||||
? senderContact.name
|
||||
: null;
|
||||
final hash = ReactionHelper.computeReactionHash(
|
||||
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
message.text,
|
||||
);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendMessage(widget.contact, reactionText);
|
||||
connector.sendMessage(_resolveContact(connector), reactionText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
|
||||
try {
|
||||
// Parse the community data
|
||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||
@@ -209,6 +212,8 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
bool addPublicChannel,
|
||||
) async {
|
||||
// Save community to local storage
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
await _communityStore.addCommunity(community);
|
||||
|
||||
// Optionally add the community public channel to the device
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:meshcore_open/services/notification_service.dart';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -64,6 +65,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
super.initState();
|
||||
_loadGroups();
|
||||
_setupFrameListener();
|
||||
_clearAdvertNotifications();
|
||||
}
|
||||
|
||||
void _clearAdvertNotifications() {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
|
||||
NotificationService().clearAdvertNotifications(contactIds);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/discovery_contact.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
@@ -129,7 +129,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
}
|
||||
|
||||
Future<void> _showContactContextMenu(
|
||||
DiscoveryContact contact,
|
||||
Contact contact,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
@@ -169,7 +169,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
connector.importDiscoveredContact(contact);
|
||||
break;
|
||||
case 'copy_contact':
|
||||
final hexString = pubKeyToHex(contact.rawPacket);
|
||||
if (contact.rawPacket == null) return;
|
||||
final hexString = pubKeyToHex(contact.rawPacket!);
|
||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -207,7 +208,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
}
|
||||
|
||||
Widget _buildFilters(
|
||||
List<DiscoveryContact> filteredAndSorted,
|
||||
List<Contact> filteredAndSorted,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
String hintText = "";
|
||||
@@ -309,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveryContact> _filterAndSortContacts(
|
||||
List<DiscoveryContact> contacts,
|
||||
List<Contact> _filterAndSortContacts(
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
@@ -350,7 +351,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(DiscoveryContact contact) {
|
||||
bool _matchesTypeFilter(Contact contact) {
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
|
||||
+137
-39
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -50,7 +51,8 @@ class MapScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MapScreenState extends State<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 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) {
|
||||
if (values.length <= 1) {
|
||||
return 0.0;
|
||||
@@ -126,7 +137,15 @@ class _MapScreenState extends State<MapScreen> {
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
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 sharedMarkers = settings.mapShowMarkers
|
||||
? _collectSharedMarkers(connector)
|
||||
@@ -159,14 +178,21 @@ class _MapScreenState extends State<MapScreen> {
|
||||
: filteredByTime;
|
||||
|
||||
// Filter by location
|
||||
final contactsWithLocation = filteredByKeyPrefix
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
||||
if (!c.hasLocation) {
|
||||
return false;
|
||||
}
|
||||
return _checkLocationPlausibility(c.latitude!, c.longitude!);
|
||||
}).toList();
|
||||
|
||||
// All contacts with a known location — used as anchors regardless of
|
||||
// time/key-prefix filters so that repeaters are always available.
|
||||
final allContactsWithLocation = contacts
|
||||
.where((c) => c.hasLocation)
|
||||
final allContactsWithLocation = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.hasLocation &&
|
||||
_checkLocationPlausibility(c.latitude!, c.longitude!),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Compute guessed locations with caching
|
||||
@@ -468,7 +494,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
...guessedLocations.map(_buildGuessedMarker),
|
||||
..._buildGuessedMarker(
|
||||
guessedLocations,
|
||||
showLabels: _showNodeLabels,
|
||||
),
|
||||
..._buildMarkers(
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
@@ -630,6 +659,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
anchors[0].latitude + offsetDeg * cos(angle),
|
||||
anchors[0].longitude + offsetDeg * sin(angle),
|
||||
);
|
||||
|
||||
if (!_checkLocationPlausibility(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
)) {
|
||||
continue; // discard implausible guesses near (0, 0)
|
||||
}
|
||||
} else {
|
||||
double lat = 0, lon = 0;
|
||||
for (final a in anchors) {
|
||||
@@ -637,6 +673,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
lon += a.longitude;
|
||||
}
|
||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||
if (!_checkLocationPlausibility(
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
)) {
|
||||
continue; // discard implausible guesses near (0, 0
|
||||
}
|
||||
}
|
||||
result.add(
|
||||
_GuessedLocation(
|
||||
@@ -710,40 +752,61 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
return Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
List<Marker> _buildGuessedMarker(
|
||||
List<_GuessedLocation> guessed, {
|
||||
required bool showLabels,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
|
||||
for (final guess in guessed) {
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
final marker = Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(
|
||||
alpha: guess.highConfidence ? 0.55 : 0.30,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.not_listed_location,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.not_listed_location,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
markers.add(marker);
|
||||
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: guess.position,
|
||||
label: guess.contact.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(
|
||||
@@ -1203,6 +1266,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Contact contact, {
|
||||
LatLng? guessedPosition,
|
||||
}) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -1248,6 +1312,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
advTypeChat) // Only show chat button for chat nodes
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (!contact.isActive) {
|
||||
connector.importDiscoveredContact(contact);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1261,6 +1328,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (contact.type == advTypeRepeater)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (!contact.isActive) {
|
||||
connector.importDiscoveredContact(contact);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
@@ -1269,6 +1339,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
if (contact.type == advTypeRoom)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (!contact.isActive) {
|
||||
connector.importDiscoveredContact(contact);
|
||||
}
|
||||
Navigator.pop(dialogContext);
|
||||
_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(
|
||||
leading: const Icon(Icons.close),
|
||||
title: Text(context.l10n.common_cancel),
|
||||
@@ -1745,6 +1835,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showDiscoveryContacts),
|
||||
value: settings.mapShowDiscoveryContacts,
|
||||
onChanged: (value) {
|
||||
service.setMapShowDiscoveryContacts(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_keyPrefix,
|
||||
|
||||
@@ -124,12 +124,14 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
|
||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||
final buffer = BufferReader(frame);
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
try {
|
||||
final neighborCount = buffer.readUInt16LE();
|
||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||
connector.contacts.where((c) => c.type == advTypeRepeater).forEach((
|
||||
repeater,
|
||||
) {
|
||||
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
|
||||
for (var neighborData in parsedNeighbors) {
|
||||
final publicKey = neighborData['publicKey'];
|
||||
if (listEquals(
|
||||
|
||||
@@ -114,14 +114,37 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Uint8List addReturnPath(Uint8List pathBytes) {
|
||||
Uint8List? traceBytes;
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
Uint8List buildPath(Uint8List pathBytes) {
|
||||
Uint8List traceBytes;
|
||||
|
||||
if (pathBytes.isEmpty) {
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
if (widget.targetContact?.type == advTypeRepeater ||
|
||||
widget.targetContact?.type == advTypeRoom) {
|
||||
final len = (pathBytes.length + pathBytes.length + 1);
|
||||
traceBytes = Uint8List(len);
|
||||
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pathBytes.length < 2) {
|
||||
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
||||
}
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
@@ -142,11 +165,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
: widget.path;
|
||||
|
||||
if (widget.flipPathRound) {
|
||||
path = addReturnPath(pathTmp);
|
||||
path = buildPath(pathTmp);
|
||||
} else {
|
||||
path = pathTmp;
|
||||
}
|
||||
|
||||
appLogger.info(
|
||||
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||
tag: 'PathTraceMapScreen',
|
||||
);
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
@@ -235,10 +263,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((
|
||||
repeater,
|
||||
) {
|
||||
final contacts = <Contact>[
|
||||
...connector.contacts,
|
||||
...connector.discoveredContacts,
|
||||
];
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../utils/app_logger.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'tcp_screen.dart';
|
||||
import 'usb_screen.dart';
|
||||
|
||||
/// Screen for scanning and connecting to MeshCore devices
|
||||
@@ -125,61 +126,78 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
connector.state == MeshCoreConnectionState.scanning;
|
||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||
final usbSupported = PlatformInfo.supportsUsbSerial;
|
||||
final tcpSupported = !PlatformInfo.isWeb;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (usbSupported)
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (usbSupported)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
appLogger.info(
|
||||
'USB selected, opening UsbScreen',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||
);
|
||||
},
|
||||
heroTag: 'scanner_usb_action',
|
||||
icon: const Icon(Icons.usb),
|
||||
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||
),
|
||||
if (usbSupported) const SizedBox(width: 12),
|
||||
if (tcpSupported)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||
);
|
||||
},
|
||||
heroTag: 'scanner_tcp_action',
|
||||
icon: const Icon(Icons.lan),
|
||||
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||
),
|
||||
if (tcpSupported) const SizedBox(width: 12),
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
appLogger.info(
|
||||
'USB selected, opening UsbScreen',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||
);
|
||||
},
|
||||
heroTag: 'scanner_usb_action',
|
||||
icon: const Icon(Icons.usb),
|
||||
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||
heroTag: 'scanner_ble_action',
|
||||
onPressed: isBluetoothOff
|
||||
? null
|
||||
: () {
|
||||
if (isScanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
unawaited(
|
||||
connector.startScan().catchError((e) {
|
||||
appLogger.warn(
|
||||
'startScan error: $e',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
: context.l10n.scanner_scan,
|
||||
),
|
||||
),
|
||||
if (usbSupported) const SizedBox(width: 12),
|
||||
FloatingActionButton.extended(
|
||||
heroTag: 'scanner_ble_action',
|
||||
onPressed: isBluetoothOff
|
||||
? null
|
||||
: () {
|
||||
if (isScanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
unawaited(
|
||||
connector.startScan().catchError((e) {
|
||||
appLogger.warn(
|
||||
'startScan error: $e',
|
||||
tag: 'ScannerScreen',
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching),
|
||||
label: Text(
|
||||
isScanning
|
||||
? context.l10n.scanner_stop
|
||||
: context.l10n.scanner_scan,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+78
-42
@@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'scanner_screen.dart';
|
||||
import 'tcp_screen.dart';
|
||||
|
||||
class UsbScreen extends StatefulWidget {
|
||||
const UsbScreen({super.key});
|
||||
@@ -107,44 +108,69 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
bottomNavigationBar: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final isLoading = _isLoadingPorts;
|
||||
final showBle = PlatformInfo.isWeb ||
|
||||
PlatformInfo.isAndroid ||
|
||||
PlatformInfo.isIOS;
|
||||
final showBle = true;
|
||||
final showTcp = !PlatformInfo.isWeb;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (showBle)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ScannerScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
heroTag: 'usb_ble_action',
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||
),
|
||||
if (showBle) const SizedBox(width: 12),
|
||||
if (!_supportsHotPlug)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: isLoading ? null : _loadPorts,
|
||||
heroTag: 'usb_refresh_action',
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
label: Text(context.l10n.repeater_refresh),
|
||||
),
|
||||
],
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (showTcp)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
||||
);
|
||||
},
|
||||
heroTag: 'usb_tcp_action',
|
||||
extendedPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
icon: const Icon(Icons.lan),
|
||||
label: Text(context.l10n.connectionChoiceTcpLabel),
|
||||
),
|
||||
if (showTcp && showBle) const SizedBox(width: 12),
|
||||
if (showBle)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ScannerScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
heroTag: 'usb_ble_action',
|
||||
extendedPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
icon: const Icon(Icons.bluetooth),
|
||||
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||
),
|
||||
if ((showTcp || showBle) && !_supportsHotPlug)
|
||||
const SizedBox(width: 12),
|
||||
if (!_supportsHotPlug)
|
||||
FloatingActionButton.extended(
|
||||
onPressed: isLoading ? null : _loadPorts,
|
||||
heroTag: 'usb_refresh_action',
|
||||
extendedPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.usb),
|
||||
label: Text(context.l10n.scanner_scan),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -191,9 +217,18 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
children: [
|
||||
Icon(Icons.circle, size: 12, color: statusColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -238,7 +273,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
|
||||
final isConnecting =
|
||||
connector.state == MeshCoreConnectionState.connecting &&
|
||||
connector.activeTransport == MeshCoreTransportType.usb;
|
||||
connector.activeTransport == MeshCoreTransportType.usb;
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -259,8 +294,7 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
),
|
||||
subtitle: showRawName ? Text(rawName) : null,
|
||||
trailing: ElevatedButton(
|
||||
onPressed:
|
||||
isConnecting ? null : () => _connectPort(port),
|
||||
onPressed: isConnecting ? null : () => _connectPort(port),
|
||||
child: Text(l10n.common_connect),
|
||||
),
|
||||
onTap: isConnecting ? null : () => _connectPort(port),
|
||||
@@ -329,8 +363,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||
|
||||
final rawPortName = normalizeUsbPortName(port);
|
||||
appLogger.info('Connect tapped for $port (raw: $rawPortName)',
|
||||
tag: 'UsbScreen');
|
||||
appLogger.info(
|
||||
'Connect tapped for $port (raw: $rawPortName)',
|
||||
tag: 'UsbScreen',
|
||||
);
|
||||
|
||||
try {
|
||||
await _connector.connectUsb(portName: rawPortName);
|
||||
|
||||
@@ -134,6 +134,10 @@ class AppSettingsService extends ChangeNotifier {
|
||||
appLogger.setEnabled(value);
|
||||
}
|
||||
|
||||
Future<void> setMapShowDiscoveryContacts(bool value) async {
|
||||
await updateSettings(_settings.copyWith(mapShowDiscoveryContacts: value));
|
||||
}
|
||||
|
||||
Future<void> setBatteryChemistryForDevice(
|
||||
String deviceId,
|
||||
String chemistry,
|
||||
|
||||
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
|
||||
[]; // Rolling buffer of recent ACK hashes
|
||||
final Map<String, List<String>> _pendingMessageQueuePerContact =
|
||||
{}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed)
|
||||
final Map<String, List<String>> _sendQueue =
|
||||
{}; // contactPubKeyHex → ordered list of messageIds awaiting send
|
||||
final Set<String> _activeMessages =
|
||||
{}; // messageIds currently in-flight (sent/retrying)
|
||||
final Set<String> _resolvedMessages =
|
||||
{}; // messageIds already resolved (prevents double _onMessageResolved)
|
||||
final Map<String, String> _expectedHashToMessageId =
|
||||
{}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash)
|
||||
|
||||
@@ -156,7 +162,49 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_addMessageCallback!(contact.publicKeyHex, message);
|
||||
}
|
||||
|
||||
await _attemptSend(messageId);
|
||||
// Queue per contact — only one message in-flight at a time to avoid
|
||||
// overflowing the firmware's 8-entry expected_ack_table.
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_sendQueue[contactKey] ??= [];
|
||||
_sendQueue[contactKey]!.add(messageId);
|
||||
|
||||
if (!_activeMessages.any(
|
||||
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
|
||||
)) {
|
||||
_sendNextForContact(contactKey);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendNextForContact(String contactKey) {
|
||||
final queue = _sendQueue[contactKey];
|
||||
if (queue == null) return;
|
||||
|
||||
// Drain stale entries iteratively instead of recursing.
|
||||
while (queue.isNotEmpty) {
|
||||
final messageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(messageId)) {
|
||||
_activeMessages.add(messageId);
|
||||
_attemptSend(messageId).catchError((e) {
|
||||
debugPrint('_attemptSend threw for $messageId: $e');
|
||||
final msg = _pendingMessages[messageId];
|
||||
if (msg != null) {
|
||||
final failed = msg.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failed;
|
||||
_updateMessageCallback?.call(failed);
|
||||
}
|
||||
_onMessageResolved(messageId, contactKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Message was cancelled/cleaned up while queued — try next
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessageResolved(String messageId, String contactKey) {
|
||||
if (_resolvedMessages.contains(messageId)) return;
|
||||
_resolvedMessages.add(messageId);
|
||||
_activeMessages.remove(messageId);
|
||||
_sendNextForContact(contactKey);
|
||||
}
|
||||
|
||||
Future<void> _attemptSend(String messageId) async {
|
||||
@@ -169,13 +217,11 @@ class MessageRetryService extends ChangeNotifier {
|
||||
// Use the path that was captured when the message was first sent
|
||||
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
|
||||
if (message.pathLength != null && message.pathLength! < 0) {
|
||||
// Flood mode - clear the path
|
||||
debugPrint(
|
||||
'Setting flood mode for retry attempt ${message.retryCount}',
|
||||
);
|
||||
_clearContactPathCallback!(contact);
|
||||
await _clearContactPathCallback!(contact);
|
||||
} else if (message.pathLength != null && message.pathLength! >= 0) {
|
||||
// Specific path (including direct neighbor with pathLength=0)
|
||||
final pathStr = message.pathBytes.isEmpty
|
||||
? 'direct'
|
||||
: message.pathBytes
|
||||
@@ -192,6 +238,24 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after async gap — a timer or ACK could have resolved/retried
|
||||
// this message while we were awaiting the path callback.
|
||||
final currentMessage = _pendingMessages[messageId];
|
||||
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
|
||||
debugPrint(
|
||||
'_attemptSend: message $messageId resolved during path sync, aborting',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// If the message was retried by a timer during our await, the retryCount
|
||||
// will have advanced. Only proceed if it still matches the attempt we started.
|
||||
if (currentMessage.retryCount != message.retryCount) {
|
||||
debugPrint(
|
||||
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final attempt = message.retryCount.clamp(0, 3);
|
||||
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
@@ -231,6 +295,15 @@ class MessageRetryService extends ChangeNotifier {
|
||||
|
||||
if (_sendMessageCallback != null) {
|
||||
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
|
||||
} else {
|
||||
// No send callback — message would be stuck forever. Fail it immediately.
|
||||
debugPrint(
|
||||
'_attemptSend: no sendMessageCallback, failing message $messageId',
|
||||
);
|
||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
_updateMessageCallback?.call(failedMessage);
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +354,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
|
||||
// Only match within a single contact's queue to avoid cross-contact mismatches.
|
||||
if (messageId == null && allowQueueFallback) {
|
||||
_debugLogService?.warn(
|
||||
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
|
||||
@@ -290,13 +364,16 @@ class MessageRetryService extends ChangeNotifier {
|
||||
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
|
||||
);
|
||||
|
||||
for (var entry in _pendingMessageQueuePerContact.entries) {
|
||||
// Search all contact queues so concurrent chats don't miss matches.
|
||||
final queuesToSearch = _pendingMessageQueuePerContact;
|
||||
|
||||
for (var entry in queuesToSearch.entries) {
|
||||
final contactKey = entry.key;
|
||||
final queue = entry.value;
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
// Drain stale entries until we find a valid one or exhaust the queue.
|
||||
while (queue.isNotEmpty) {
|
||||
final candidateMessageId = queue.removeAt(0);
|
||||
|
||||
if (_pendingMessages.containsKey(candidateMessageId)) {
|
||||
messageId = candidateMessageId;
|
||||
contact = _pendingContacts[candidateMessageId];
|
||||
@@ -304,21 +381,10 @@ class MessageRetryService extends ChangeNotifier {
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
if (queue.isNotEmpty) {
|
||||
final nextMessageId = queue.removeAt(0);
|
||||
if (_pendingMessages.containsKey(nextMessageId)) {
|
||||
messageId = nextMessageId;
|
||||
contact = _pendingContacts[nextMessageId];
|
||||
debugPrint(
|
||||
'Queue-based match (fallback): $ackHashHex → message $messageId',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
debugPrint('Dequeued stale message $candidateMessageId - skipping');
|
||||
}
|
||||
if (messageId != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,22 +529,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
} else {
|
||||
// Max retries reached - mark as failed
|
||||
final failedMessage = message.copyWith(status: MessageStatus.failed);
|
||||
|
||||
// Move ACK hashes to history before removing
|
||||
_moveAckHashesToHistory(messageId);
|
||||
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_pendingPathSelections.remove(messageId);
|
||||
_timeoutTimers[messageId]?.cancel();
|
||||
_timeoutTimers.remove(messageId);
|
||||
|
||||
// Clean up the queue entry for this contact
|
||||
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
|
||||
false) {
|
||||
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
|
||||
}
|
||||
_pendingMessages[messageId] = failedMessage;
|
||||
|
||||
// Check if we should clear the path on max retry
|
||||
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
|
||||
@@ -499,6 +550,30 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Message is done retrying — send next queued message for this contact
|
||||
_onMessageResolved(messageId, contact.publicKeyHex);
|
||||
|
||||
// Keep message in pending maps for 30s grace period so late ACKs
|
||||
// can still match and update the message to delivered.
|
||||
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
|
||||
_moveAckHashesToHistory(messageId);
|
||||
// Clean up ALL hash mappings for this message
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == messageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
|
||||
_pendingMessages.remove(messageId);
|
||||
_pendingContacts.remove(messageId);
|
||||
_pendingPathSelections.remove(messageId);
|
||||
_timeoutTimers.remove(messageId);
|
||||
_resolvedMessages.remove(messageId);
|
||||
final contactKey = contact.publicKeyHex;
|
||||
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
|
||||
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
|
||||
_pendingMessageQueuePerContact.remove(contactKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +669,15 @@ class MessageRetryService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
if (matchedMessageId != null) {
|
||||
final message = _pendingMessages[matchedMessageId]!;
|
||||
final message = _pendingMessages[matchedMessageId];
|
||||
if (message == null) {
|
||||
// Message was already cleaned up (e.g. grace period expired)
|
||||
_ackHashToMessageId.remove(ackHashHex);
|
||||
debugPrint(
|
||||
'ACK matched $matchedMessageId but message already cleaned up',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final contact = _pendingContacts[matchedMessageId];
|
||||
final selection = _pendingPathSelections[matchedMessageId];
|
||||
|
||||
@@ -616,12 +699,21 @@ class MessageRetryService extends ChangeNotifier {
|
||||
tripTimeMs: tripTimeMs,
|
||||
);
|
||||
|
||||
// Clean up ALL hash mappings for this message (from all retry attempts)
|
||||
_ackHashToMessageId.removeWhere(
|
||||
(_, mapping) => mapping.messageId == matchedMessageId,
|
||||
);
|
||||
_expectedHashToMessageId.removeWhere(
|
||||
(_, msgId) => msgId == matchedMessageId,
|
||||
);
|
||||
|
||||
// Move ACK hashes to history before removing
|
||||
_moveAckHashesToHistory(matchedMessageId);
|
||||
|
||||
_pendingMessages.remove(matchedMessageId);
|
||||
_pendingContacts.remove(matchedMessageId);
|
||||
_pendingPathSelections.remove(matchedMessageId);
|
||||
_resolvedMessages.remove(matchedMessageId);
|
||||
|
||||
// Clean up the queue entry for this contact (remove any remaining references to this message)
|
||||
if (contact != null) {
|
||||
@@ -646,6 +738,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
true,
|
||||
tripTimeMs,
|
||||
);
|
||||
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier {
|
||||
_ackHistory.clear();
|
||||
_ackHashToMessageId.clear();
|
||||
_pendingMessageQueuePerContact.clear();
|
||||
_sendQueue.clear();
|
||||
_activeMessages.clear();
|
||||
_resolvedMessages.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,7 @@ class NotificationService {
|
||||
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
|
||||
if (addr != null && addr.isNotEmpty) return true;
|
||||
// Fallback: check the default socket for the current user.
|
||||
final uid = Platform.environment['UID'] ??
|
||||
Platform.environment['EUID'];
|
||||
final uid = Platform.environment['UID'] ?? Platform.environment['EUID'];
|
||||
final path = '/run/user/${uid ?? '1000'}/bus';
|
||||
return File(path).existsSync();
|
||||
}
|
||||
@@ -233,7 +232,9 @@ class NotificationService {
|
||||
|
||||
try {
|
||||
await _notifications.show(
|
||||
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
id: contactId != null
|
||||
? 'advert:$contactId'.hashCode
|
||||
: DateTime.now().millisecondsSinceEpoch,
|
||||
title: _l10n.notification_newTypeDiscovered(contactType),
|
||||
body: contactName,
|
||||
notificationDetails: notificationDetails,
|
||||
@@ -332,6 +333,61 @@ class NotificationService {
|
||||
await _notifications.cancel(id: id);
|
||||
}
|
||||
|
||||
/// Cancel the notification for a specific contact and update the app badge.
|
||||
Future<void> clearContactNotification(
|
||||
String contactId,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: contactId.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
}
|
||||
|
||||
/// Cancel the notification for a specific channel and update the app badge.
|
||||
Future<void> clearChannelNotification(
|
||||
int channelIndex,
|
||||
int totalUnreadCount,
|
||||
) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
await _notifications.cancel(id: channelIndex.hashCode);
|
||||
await _updateBadge(totalUnreadCount);
|
||||
}
|
||||
|
||||
/// Cancel advert notifications for the given contact public key hexes.
|
||||
Future<void> clearAdvertNotifications(List<String> contactIds) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
for (final id in contactIds) {
|
||||
await _notifications.cancel(id: 'advert:$id'.hashCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateBadge(int count) async {
|
||||
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||
// On Apple platforms, set the badge number directly via a silent update.
|
||||
final darwinDetails = DarwinNotificationDetails(
|
||||
presentAlert: false,
|
||||
presentSound: false,
|
||||
presentBadge: true,
|
||||
badgeNumber: count,
|
||||
);
|
||||
final details = NotificationDetails(
|
||||
iOS: darwinDetails,
|
||||
macOS: darwinDetails,
|
||||
);
|
||||
// Use a fixed ID so each update replaces the previous one.
|
||||
await _notifications.show(
|
||||
id: 'badge_update'.hashCode,
|
||||
title: null,
|
||||
body: null,
|
||||
notificationDetails: details,
|
||||
);
|
||||
// Immediately cancel the silent notification so it doesn't appear in tray.
|
||||
await _notifications.cancel(id: 'badge_update'.hashCode);
|
||||
}
|
||||
// On Android, badge count is derived from active notifications,
|
||||
// so cancelling the specific notification above is sufficient.
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Public notification methods (rate limiting is enforced automatically)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -118,10 +118,7 @@ class UsbSerialService {
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
} catch (error) {
|
||||
_debugLogService?.error(
|
||||
'Web connect failed: $error',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
_debugLogService?.error('Web connect failed: $error', tag: 'USB Serial');
|
||||
await _cleanupFailedConnect();
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
_connectedPortName = null;
|
||||
@@ -268,9 +265,23 @@ class UsbSerialService {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _openPort(JSObject port, int baudRate) {
|
||||
final options = JSObject()..['baudRate'] = baudRate.toJS;
|
||||
return port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||
Future<void> _openPort(JSObject port, int baudRate) async {
|
||||
final options = JSObject()
|
||||
..['baudRate'] = baudRate.toJS
|
||||
..['flowControl'] = 'none'.toJS;
|
||||
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||
|
||||
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
|
||||
try {
|
||||
final signals = JSObject()
|
||||
..['dataTerminalReady'] = true.toJS
|
||||
..['requestToSend'] = false.toJS;
|
||||
await port
|
||||
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||
.toDart;
|
||||
} catch (_) {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupFailedConnect() async {
|
||||
@@ -324,8 +335,12 @@ class UsbSerialService {
|
||||
|
||||
Future<void> _pumpReads() async {
|
||||
final reader = _reader;
|
||||
if (reader == null) return;
|
||||
if (reader == null) {
|
||||
_debugLogService?.warn('_pumpReads: reader is null', tag: 'USB Serial');
|
||||
return;
|
||||
}
|
||||
|
||||
_debugLogService?.info('_pumpReads: started', tag: 'USB Serial');
|
||||
try {
|
||||
while (_status == UsbSerialStatus.connected &&
|
||||
identical(reader, _reader)) {
|
||||
@@ -333,6 +348,7 @@ class UsbSerialService {
|
||||
.callMethod<JSPromise<JSAny?>>('read'.toJS)
|
||||
.toDart;
|
||||
if (result == null) {
|
||||
_debugLogService?.warn('_pumpReads: null result', tag: 'USB Serial');
|
||||
break;
|
||||
}
|
||||
final resultObject = result as JSObject;
|
||||
@@ -340,20 +356,27 @@ class UsbSerialService {
|
||||
final doneValue = resultObject.getProperty<JSAny?>('done'.toJS);
|
||||
final done = doneValue != null && doneValue.dartify() == true;
|
||||
if (done) {
|
||||
_debugLogService?.info('_pumpReads: done=true', tag: 'USB Serial');
|
||||
break;
|
||||
}
|
||||
|
||||
final value = resultObject.getProperty<JSAny?>('value'.toJS);
|
||||
final bytes = _coerceBytes(value);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
_debugLogService?.info(
|
||||
'USB RX raw: ${bytes.length} byte(s)',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
_ingestRawBytes(bytes);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
if (_status == UsbSerialStatus.connected) {
|
||||
_addFrameError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(reader);
|
||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||
_addFrameError(StateError('USB serial connection closed'));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
|
||||
import '../models/channel_message.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
import 'prefs_manager.dart';
|
||||
@@ -7,13 +9,25 @@ import 'prefs_manager.dart';
|
||||
class ChannelMessageStore {
|
||||
static const String _keyPrefix = 'channel_messages_';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
/// Save messages for a specific channel
|
||||
Future<void> saveChannelMessages(
|
||||
int channelIndex,
|
||||
List<ChannelMessage> messages,
|
||||
) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot save channel messages.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
final key = '$keyFor$channelIndex';
|
||||
|
||||
// Convert messages to JSON
|
||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||
@@ -24,12 +38,35 @@ class ChannelMessageStore {
|
||||
|
||||
/// Load messages for a specific channel
|
||||
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot load channel messages.',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
|
||||
final jsonString = prefs.getString(key);
|
||||
if (jsonString == null) return [];
|
||||
final key = '$keyFor$channelIndex';
|
||||
final oldKey = '$_keyPrefix$channelIndex';
|
||||
|
||||
String? jsonString = prefs.getString(key);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(oldKey);
|
||||
prefs.remove(oldKey);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating channel messages from legacy key $oldKey to scoped key $key',
|
||||
);
|
||||
await prefs.setString(key, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList.map((json) => _messageFromJson(json)).toList();
|
||||
@@ -42,14 +79,14 @@ class ChannelMessageStore {
|
||||
/// Clear messages for a specific channel
|
||||
Future<void> clearChannelMessages(int channelIndex) async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
final key = '$keyFor$channelIndex';
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
/// Clear all channel messages
|
||||
Future<void> clearAllChannelMessages() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
|
||||
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor));
|
||||
for (var key in keys) {
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import 'dart:convert';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelOrderStore {
|
||||
static const String _key = 'channel_order';
|
||||
static const String _keyPrefix = 'channel_order_';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<void> saveChannelOrder(List<int> order) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save channel order.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.setString(_key, jsonEncode(order));
|
||||
await prefs.setString(keyFor, jsonEncode(order));
|
||||
}
|
||||
|
||||
Future<List<int>> loadChannelOrder() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load channel order.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
return decoded
|
||||
.map((value) => value is int ? value : int.tryParse('$value'))
|
||||
@@ -24,7 +53,7 @@ class ChannelOrderStore {
|
||||
} catch (_) {
|
||||
// fall through to legacy parse
|
||||
}
|
||||
return raw
|
||||
return jsonString
|
||||
.split(',')
|
||||
.map((value) => int.tryParse(value))
|
||||
.whereType<int>()
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelSettingsStore {
|
||||
static const String _smazKeyPrefix = 'channel_smaz_';
|
||||
static const String _keyPrefix = 'channel_smaz_';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<bool> loadSmazEnabled(int channelIndex) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot load channel settings.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$channelIndex';
|
||||
return prefs.getBool(key) ?? false;
|
||||
final key = '$keyFor$channelIndex';
|
||||
final oldKey = '$_keyPrefix$channelIndex';
|
||||
bool? enabled = prefs.getBool(oldKey);
|
||||
if (enabled == null) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
enabled = prefs.getBool(oldKey);
|
||||
prefs.remove(oldKey);
|
||||
if (enabled != null) {
|
||||
appLogger.info(
|
||||
'Migrating channel settings from legacy key $oldKey to scoped key $key',
|
||||
);
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
}
|
||||
return enabled ?? false;
|
||||
}
|
||||
|
||||
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot save channel settings.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$channelIndex';
|
||||
final key = '$keyFor$channelIndex';
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelStore {
|
||||
static const String _key = 'channels';
|
||||
static const String _keyPrefix = 'channels';
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<List<Channel>> loadChannels() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load channels.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -23,9 +51,13 @@ class ChannelStore {
|
||||
}
|
||||
|
||||
Future<void> saveChannels(List<Channel> channels) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save channels.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonList = channels.map(_toJson).toList();
|
||||
await prefs.setString(_key, jsonEncode(jsonList));
|
||||
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toJson(Channel channel) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/community.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
/// Persists communities to local storage using SharedPreferences.
|
||||
@@ -9,12 +10,37 @@ import 'prefs_manager.dart';
|
||||
/// Each community contains its secret K, so this data should
|
||||
/// be considered sensitive (though device encryption handles security).
|
||||
class CommunityStore {
|
||||
static const String _communitiesKey = 'communities_v1';
|
||||
static const String _keyPrefix = 'communities_v1';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
/// Load all communities from storage
|
||||
Future<List<Community>> loadCommunities() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load communities.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonString = prefs.getString(_communitiesKey);
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
@@ -32,9 +58,13 @@ class CommunityStore {
|
||||
|
||||
/// Save all communities to storage
|
||||
Future<void> saveCommunities(List<Community> communities) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save communities.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonList = communities.map((c) => c.toJson()).toList();
|
||||
await prefs.setString(_communitiesKey, jsonEncode(jsonList));
|
||||
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
/// Add a new community
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/discovery_contact.dart';
|
||||
import '../models/contact.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactDiscoveryStore {
|
||||
static const String _key = 'discovered_contacts';
|
||||
static const String _keyPrefix = 'discovered_contacts';
|
||||
|
||||
Future<List<DiscoveryContact>> loadContacts() async {
|
||||
Future<List<Contact>> loadContacts() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_key);
|
||||
final jsonStr = prefs.getString(_keyPrefix);
|
||||
if (jsonStr == null) return [];
|
||||
|
||||
try {
|
||||
@@ -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 jsonList = contacts.map(_toJson).toList();
|
||||
await prefs.setString(_key, jsonEncode(jsonList));
|
||||
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toJson(DiscoveryContact contact) {
|
||||
Map<String, dynamic> _toJson(Contact contact) {
|
||||
return {
|
||||
'rawPacket': base64Encode(contact.rawPacket),
|
||||
'publicKey': base64Encode(contact.publicKey),
|
||||
'name': contact.name,
|
||||
'type': contact.type,
|
||||
'flags': contact.flags,
|
||||
'pathLength': contact.pathLength,
|
||||
'path': base64Encode(contact.path),
|
||||
'pathOverride': contact.pathOverride,
|
||||
'pathOverrideBytes': contact.pathOverrideBytes != null
|
||||
? base64Encode(contact.pathOverrideBytes!)
|
||||
: null,
|
||||
'latitude': contact.latitude,
|
||||
'longitude': contact.longitude,
|
||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||
'rawPacket': contact.rawPacket != null
|
||||
? base64Encode(contact.rawPacket!)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
DiscoveryContact _fromJson(Map<String, dynamic> json) {
|
||||
Contact _fromJson(Map<String, dynamic> json) {
|
||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||
return DiscoveryContact(
|
||||
rawPacket: Uint8List.fromList(base64Decode(json['rawPacket'] as String)),
|
||||
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||
name: json['name'] as String? ?? 'Unknown',
|
||||
type: json['type'] as int? ?? 0,
|
||||
flags: json['flags'] as int? ?? 0,
|
||||
pathLength: json['pathLength'] as int? ?? -1,
|
||||
path: json['path'] != null
|
||||
? Uint8List.fromList(base64Decode(json['path'] as String))
|
||||
: Uint8List(0),
|
||||
pathOverride: json['pathOverride'] as int?,
|
||||
pathOverrideBytes: json['pathOverrideBytes'] != null
|
||||
? Uint8List.fromList(
|
||||
base64Decode(json['pathOverrideBytes'] as String),
|
||||
)
|
||||
: null,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastMessageMs ?? lastSeenMs,
|
||||
),
|
||||
isActive: false,
|
||||
rawPacket: json['rawPacket'] != null
|
||||
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import 'dart:convert';
|
||||
import '../models/contact_group.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactGroupStore {
|
||||
static const String _key = 'contact_groups';
|
||||
static const String _keyPrefix = 'contact_groups';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<List<ContactGroup>> loadGroups() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load contact groups.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
return decoded
|
||||
.whereType<Map<String, dynamic>>()
|
||||
@@ -25,8 +53,12 @@ class ContactGroupStore {
|
||||
}
|
||||
|
||||
Future<void> saveGroups(List<ContactGroup> groups) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save contact groups.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
||||
await prefs.setString(_key, encoded);
|
||||
await prefs.setString(keyFor, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactSettingsStore {
|
||||
static const String _smazKeyPrefix = 'contact_smaz_';
|
||||
static const String _keyPrefix = 'contact_smaz_';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot load contact settings.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
||||
final key = '$keyFor$contactKeyHex';
|
||||
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||
bool? enabled = prefs.getBool(key);
|
||||
if (enabled == null) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
enabled = prefs.getBool(oldKey);
|
||||
prefs.remove(oldKey);
|
||||
if (enabled != null) {
|
||||
appLogger.info(
|
||||
'Migrating contact settings from legacy key $oldKey to scoped key $key',
|
||||
);
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
}
|
||||
return prefs.getBool(key) ?? false;
|
||||
}
|
||||
|
||||
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn(
|
||||
'Public key hex is not set. Cannot save contact settings.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
||||
final key = '$keyFor$contactKeyHex';
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,46 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactStore {
|
||||
static const String _key = 'contacts';
|
||||
static const String _keyPrefix = 'contacts';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<List<Contact>> loadContacts() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load contacts.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((entry) => _fromJson(entry as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -23,9 +51,13 @@ class ContactStore {
|
||||
}
|
||||
|
||||
Future<void> saveContacts(List<Contact> contacts) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save contacts.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonList = contacts.map(_toJson).toList();
|
||||
await prefs.setString(_key, jsonEncode(jsonList));
|
||||
await prefs.setString(keyFor, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _toJson(Contact contact) {
|
||||
@@ -44,6 +76,10 @@ class ContactStore {
|
||||
'longitude': contact.longitude,
|
||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||
'isActive': contact.isActive,
|
||||
'rawPacket': contact.rawPacket != null
|
||||
? base64Encode(contact.rawPacket!)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +107,10 @@ class ContactStore {
|
||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastMessageMs ?? lastSeenMs,
|
||||
),
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
rawPacket: json['rawPacket'] != null
|
||||
? Uint8List.fromList(base64Decode(json['rawPacket'] as String))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,59 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../models/message.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class MessageStore {
|
||||
static const String _keyPrefix = 'messages_';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length > 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
Future<void> saveMessages(
|
||||
String contactKeyHex,
|
||||
List<Message> messages,
|
||||
) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save messages.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final key = '$keyFor$contactKeyHex';
|
||||
final jsonList = messages.map(_messageToJson).toList();
|
||||
await prefs.setString(key, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load messages.');
|
||||
return [];
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final jsonString = prefs.getString(key);
|
||||
if (jsonString == null) return [];
|
||||
final key = '$keyFor$contactKeyHex';
|
||||
final oldKey = '$_keyPrefix$contactKeyHex';
|
||||
String? jsonString = prefs.getString(key);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(oldKey);
|
||||
prefs.remove(oldKey);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating messages from legacy key $oldKey to scoped key $key',
|
||||
);
|
||||
await prefs.setString(key, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
@@ -32,8 +65,12 @@ class MessageStore {
|
||||
}
|
||||
|
||||
Future<void> clearMessages(String contactKeyHex) async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot clear messages.');
|
||||
return;
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final key = '$keyFor$contactKeyHex';
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
||||
class UnreadStore {
|
||||
static const String _contactUnreadCountKey = 'contact_unread_count';
|
||||
static const String _keyPrefix = 'contact_unread_count';
|
||||
|
||||
String publicKeyHex = '';
|
||||
set setPublicKeyHex(String value) =>
|
||||
publicKeyHex = value.length >= 10 ? value.substring(0, 10) : '';
|
||||
|
||||
String get keyFor => '$_keyPrefix$publicKeyHex';
|
||||
|
||||
// Debounce timers to batch rapid writes
|
||||
Timer? _contactUnreadSaveTimer;
|
||||
@@ -20,12 +27,33 @@ class UnreadStore {
|
||||
}
|
||||
|
||||
Future<Map<String, int>> loadContactUnreadCount() async {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot load unread counts.');
|
||||
return {};
|
||||
}
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_contactUnreadCountKey);
|
||||
if (jsonStr == null) return {};
|
||||
String? jsonString = prefs.getString(keyFor);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
// Attempt migration from legacy unscoped key on first load
|
||||
final legacyJsonString = prefs.getString(_keyPrefix);
|
||||
prefs.remove(_keyPrefix);
|
||||
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
|
||||
appLogger.info(
|
||||
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
|
||||
);
|
||||
await prefs.setString(keyFor, legacyJsonString);
|
||||
jsonString = legacyJsonString;
|
||||
}
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
jsonString = prefs.getString(keyFor);
|
||||
}
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final json = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
return json.map((key, value) => MapEntry(key, value as int));
|
||||
} catch (_) {
|
||||
return {};
|
||||
@@ -33,6 +61,10 @@ class UnreadStore {
|
||||
}
|
||||
|
||||
void saveContactUnreadCount(Map<String, int> counts) {
|
||||
if (publicKeyHex.isEmpty) {
|
||||
appLogger.warn('Public key hex is not set. Cannot save unread counts.');
|
||||
return;
|
||||
}
|
||||
_pendingContactUnreadCount = counts;
|
||||
|
||||
_contactUnreadSaveTimer?.cancel();
|
||||
@@ -49,7 +81,7 @@ class UnreadStore {
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = jsonEncode(_pendingContactUnreadCount);
|
||||
await prefs.setString(_contactUnreadCountKey, jsonStr);
|
||||
await prefs.setString(keyFor, jsonStr);
|
||||
_pendingContactUnreadCount = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:meshcore_open/models/discovery_contact.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
bool matchesContactQuery(Contact contact, String query) {
|
||||
@@ -16,7 +14,7 @@ bool matchesContactQuery(Contact contact, String query) {
|
||||
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
||||
}
|
||||
|
||||
bool matchesDiscoveryContactQuery(DiscoveryContact contact, String query) {
|
||||
bool matchesDiscoveryContactQuery(Contact contact, String query) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
|
||||
|
||||
@@ -157,8 +157,11 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
repeater.snr,
|
||||
widget.connector.currentSf,
|
||||
);
|
||||
|
||||
final name = widget.connector.contacts
|
||||
final allContacts = [
|
||||
...widget.connector.contacts,
|
||||
...widget.connector.discoveredContacts,
|
||||
];
|
||||
final name = allContacts
|
||||
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
||||
.map((c) => c.name)
|
||||
.firstOrNull;
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||
<array>
|
||||
<string>/dev/</string>
|
||||
<string>/dev/cu.</string>
|
||||
<string>/dev/tty.</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<!-- USB serial ports (/dev/cu.* and /dev/tty.*) for LoRa device communication -->
|
||||
<key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
|
||||
<array>
|
||||
<string>/dev/</string>
|
||||
<string>/dev/cu.</string>
|
||||
<string>/dev/tty.</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_blue_plus: ^2.1.0
|
||||
# TODO: Switch to official flserial repo once changes are upstreamed
|
||||
flserial:
|
||||
git:
|
||||
url: https://github.com/MeshEnvy/flserial.git
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -116,10 +116,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 0);
|
||||
@@ -131,28 +128,24 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'UsbScreen sends raw port name when tapping Connect',
|
||||
(tester) async {
|
||||
final connector = _FakeMeshCoreConnector(
|
||||
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||
);
|
||||
testWidgets('UsbScreen sends raw port name when tapping Connect', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector(
|
||||
ports: <String>['COM6 - USB Serial Device (COM6)'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 1);
|
||||
expect(connector.lastConnectPortName, 'COM6');
|
||||
},
|
||||
);
|
||||
expect(connector.connectUsbCalls, 1);
|
||||
expect(connector.lastConnectPortName, 'COM6');
|
||||
});
|
||||
|
||||
testWidgets('ScannerScreen USB action reflects platform support', (
|
||||
tester,
|
||||
@@ -176,9 +169,72 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('ScannerScreen narrow width keeps actions without overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(ScannerScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(find.text(l10n.scanner_scan), findsOneWidget);
|
||||
|
||||
if (PlatformInfo.supportsUsbSerial) {
|
||||
expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget);
|
||||
}
|
||||
if (!PlatformInfo.isWeb) {
|
||||
expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('UsbScreen narrow width long status text does not overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector =
|
||||
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
|
||||
..fakeUsbTransportConnected = true
|
||||
..fakeActiveUsbPortDisplayLabel =
|
||||
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(UsbScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(
|
||||
find.text(
|
||||
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
group('Error Handling', () {
|
||||
testWidgets('shows error SnackBar when listing ports fails',
|
||||
(tester) async {
|
||||
testWidgets('shows error SnackBar when listing ports fails', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
connector.listUsbPortsImpl = () async {
|
||||
throw PlatformException(
|
||||
@@ -195,9 +251,7 @@ void main() {
|
||||
expect(find.text('USB permission was denied.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('connection failure shows SnackBar error', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('connection failure shows SnackBar error', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
|
||||
var connectAttempted = false;
|
||||
connector.connectUsbImpl = ({required String portName}) async {
|
||||
@@ -210,10 +264,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
));
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connectAttempted, isTrue);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user