- Implemented TCP transport service for native platforms. - Added TCP connection screen with input fields for host and port. - Integrated TCP connection options into the scanner and USB screens. - Updated localization files for new TCP-related strings. - Added tests for TCP connection flow and error handling. - Enhanced USB screen to include TCP connection option. - Improved layout to ensure no overflow in narrow widths for scanner and USB screens.chore/offband-rebrand
parent
a1b77bb29b
commit
7a2bb20bf7
@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../services/app_debug_log_service.dart';
|
||||||
|
import '../services/tcp_transport_service.dart';
|
||||||
|
|
||||||
|
class MeshCoreTcpManager {
|
||||||
|
final TcpTransportService _service = TcpTransportService();
|
||||||
|
AppDebugLogService? _debugLog;
|
||||||
|
|
||||||
|
String? get activeEndpoint => _service.activeEndpoint;
|
||||||
|
bool get isConnected => _service.isConnected;
|
||||||
|
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLog = service;
|
||||||
|
_service.setDebugLogService(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({required String host, required int port}) async {
|
||||||
|
_debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP');
|
||||||
|
await _service.connect(host: host, port: port);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
if (!_service.isConnected && _service.activeEndpoint == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debugLog?.info('TcpManager.disconnect', tag: 'TCP');
|
||||||
|
await _service.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) => _service.write(data);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_service.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../utils/platform_info.dart';
|
||||||
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import 'contacts_screen.dart';
|
||||||
|
import 'usb_screen.dart';
|
||||||
|
|
||||||
|
class TcpScreen extends StatefulWidget {
|
||||||
|
const TcpScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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(text: '192.168.40.10');
|
||||||
|
_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;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildStatusBar(context, connector),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _hostController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpHostLabel,
|
||||||
|
hintText: context.l10n.tcpHostHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _portController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.tcpPortLabel,
|
||||||
|
hintText: context.l10n.tcpPortHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
enabled: !isConnecting,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: isConnecting ? null : _connectTcp,
|
||||||
|
icon: isConnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lan),
|
||||||
|
label: Text(
|
||||||
|
isConnecting
|
||||||
|
? context.l10n.scanner_connecting
|
||||||
|
: context.l10n.common_connect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
top: false,
|
||||||
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (PlatformInfo.supportsUsbSerial)
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute(builder: (_) => const UsbScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_usb_action',
|
||||||
|
icon: const Icon(Icons.usb),
|
||||||
|
label: Text(context.l10n.connectionChoiceUsbLabel),
|
||||||
|
),
|
||||||
|
if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12),
|
||||||
|
FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
heroTag: 'tcp_ble_action',
|
||||||
|
icon: const Icon(Icons.bluetooth),
|
||||||
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
if (connector.isTcpTransportConnected) {
|
||||||
|
statusText = l10n.scanner_connectedTo(
|
||||||
|
connector.activeTcpEndpoint ?? 'TCP',
|
||||||
|
);
|
||||||
|
statusColor = Colors.green;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.tcpStatus_connectingTo(
|
||||||
|
'${_hostController.text}:${_portController.text}',
|
||||||
|
);
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
statusText = l10n.scanner_disconnecting;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
} else {
|
||||||
|
statusText = l10n.tcpStatus_notConnected;
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 12, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectTcp() async {
|
||||||
|
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
||||||
|
|
||||||
|
final host = _hostController.text.trim();
|
||||||
|
final parsedPort = int.tryParse(_portController.text.trim());
|
||||||
|
if (host.isEmpty) {
|
||||||
|
_showError(context.l10n.tcpErrorHostRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||||
|
_showError(context.l10n.tcpErrorPortInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _connector.connectTcp(host: host, port: parsedPort);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showError(_friendlyErrorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _friendlyErrorMessage(Object error) {
|
||||||
|
if (error is UnsupportedError) {
|
||||||
|
return context.l10n.tcpErrorUnsupported;
|
||||||
|
}
|
||||||
|
if (error is TimeoutException) {
|
||||||
|
return context.l10n.tcpErrorTimedOut;
|
||||||
|
}
|
||||||
|
if (error is StateError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(error.message);
|
||||||
|
}
|
||||||
|
if (error is ArgumentError) {
|
||||||
|
return context.l10n.tcpConnectionFailed(
|
||||||
|
error.message?.toString() ?? error.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context.l10n.tcpConnectionFailed(error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export 'tcp_transport_service_native.dart'
|
||||||
|
if (dart.library.js_interop) 'tcp_transport_service_web.dart';
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
import 'usb_serial_frame_codec.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
final StreamController<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() {
|
||||||
|
unawaited(disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrame(Uint8List payload) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.add(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
_frameController.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _logFrameSummary(String prefix, Uint8List payload) {
|
||||||
|
final code = payload.isNotEmpty ? payload.first : -1;
|
||||||
|
_debugLogService?.info(
|
||||||
|
'$prefix code=$code len=${payload.length}',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeFrameController() async {
|
||||||
|
if (_frameController.isClosed) return;
|
||||||
|
await _frameController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TcpTransportStatus { disconnected, connecting, connected, disconnecting }
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'app_debug_log_service.dart';
|
||||||
|
|
||||||
|
class TcpTransportService {
|
||||||
|
AppDebugLogService? _debugLogService;
|
||||||
|
|
||||||
|
Stream<Uint8List> get frameStream => const Stream<Uint8List>.empty();
|
||||||
|
bool get isConnected => false;
|
||||||
|
String? get activeEndpoint => null;
|
||||||
|
|
||||||
|
void setDebugLogService(AppDebugLogService? service) {
|
||||||
|
_debugLogService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({
|
||||||
|
required String host,
|
||||||
|
required int port,
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
_debugLogService?.warn(
|
||||||
|
'TCP transport requested on web for $host:$port',
|
||||||
|
tag: 'TCP',
|
||||||
|
);
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(Uint8List data) async {
|
||||||
|
throw UnsupportedError('TCP transport is not supported on web.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {}
|
||||||
|
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
|
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||||
|
import 'package:meshcore_open/screens/tcp_screen.dart';
|
||||||
|
|
||||||
|
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||||
|
_FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
|
||||||
|
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
|
||||||
|
String? initialEndpoint;
|
||||||
|
int connectTcpCalls = 0;
|
||||||
|
String? lastHost;
|
||||||
|
int? lastPort;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreConnectionState get state => initialState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MeshCoreTransportType get activeTransport => initialTransport;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isTcpTransportConnected =>
|
||||||
|
initialState == MeshCoreConnectionState.connected &&
|
||||||
|
initialTransport == MeshCoreTransportType.tcp;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get activeTcpEndpoint => initialEndpoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<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.widgetWithText(FilledButton, 'Connect'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), '99999');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(connector.connectTcpCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TCP Bluetooth action returns to existing scanner route', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final connector = _FakeMeshCoreConnector();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TcpScreen), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(TcpScreen), findsNothing);
|
||||||
|
expect(find.byType(ScannerScreen), findsOneWidget);
|
||||||
|
final navigatorState = tester.state<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 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in new issue