From 5e9ef43f2fda9fccebf693c2f433a6b79c6830f1 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:49:08 +0200 Subject: [PATCH] udp socket binding istead of process binding to allow internet over network for maps etc --- .../kotlin/com/dmc/dmc_app/MainActivity.kt | 4 + .../com/dmc/dmc_app/MlrsNetworkPlugin.kt | 180 +++++++++++++++--- .../udp/mlrs_network_controller.dart | 113 +++++++++-- app/lib/transport/udp/udp_transport.dart | 113 +++++------ app/test/transport/udp_transport_test.dart | 122 ++++++------ 5 files changed, 365 insertions(+), 167 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MainActivity.kt index 74cc2f0..73ec9cf 100644 --- a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MainActivity.kt @@ -18,5 +18,9 @@ class MainActivity : FlutterActivity() { flutterEngine.dartExecutor.binaryMessenger, MlrsNetworkPlugin.EVENT_CHANNEL, ).setStreamHandler(plugin) + EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + MlrsNetworkPlugin.SOCKET_EVENT_CHANNEL, + ).setStreamHandler(plugin.socketStreamHandler) } } diff --git a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt index 65fbd7b..4a607b7 100644 --- a/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt +++ b/app/android/app/src/main/kotlin/com/dmc/dmc_app/MlrsNetworkPlugin.kt @@ -15,6 +15,12 @@ import android.os.PatternMatcher import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.net.BindException +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.Executors /** * Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus @@ -25,12 +31,17 @@ import io.flutter.plugin.common.MethodChannel * Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus, * das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die * passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den - * EventChannel nach Dart gemeldet, das den restlichen Ablauf (Socket binden, - * lauschen, Fehleranzeige) uebernimmt. + * EventChannel nach Dart gemeldet. * - * [bindProcessToNetwork]/[unbindProcessFromNetwork] setzen/loeschen die - * Prozessbindung fuer das kurze Fenster, in dem Dart den UDP-Socket erzeugt - - * siehe UdpTransport.connect() fuer die Gegenseite dieses Musters. + * Der UDP-Socket selbst wird ebenfalls hier (nativ) gehalten und per + * `Network.bindSocket()` **socket-genau** an das mLRS-Netz gebunden (Doku + * Abschnitt 4, Stufe 3) - bewusst NICHT ueber + * `ConnectivityManager.bindProcessToNetwork()`, das den gesamten Prozess + * (inkl. gleichzeitiger Kartenkacheln-/Wetter-HTTP-Requests) betrifft und in + * der Praxis auf Geraeten ohne STA-Concurrency zu genau diesen Requests + * gefuehrt hat, die "auf dem falschen Netz" haengen blieben. Socket-genaue + * Bindung ist Androids eigene Empfehlung fuer Apps, die gleichzeitig ein + * spezielles lokales Netz UND normales Internet brauchen. */ class MlrsNetworkPlugin( private val context: Context, @@ -39,23 +50,41 @@ class MlrsNetworkPlugin( companion object { const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network" const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events" + const val SOCKET_EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_socket_events" + private const val RECEIVE_BUFFER_SIZE = 2048 } private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - // NetworkCallback-Methoden laufen ohne expliziten Handler auf einem - // internen ConnectivityThread, nicht auf dem Main-Thread - Flutters - // EventChannel.EventSink.success() verlangt aber @UiThread (schlaegt - // sonst mit "Methods marked with @UiThread must be executed on the main - // thread" fehl). Deshalb Callback-Dispatch explizit auf den Main-Looper - // legen. + // NetworkCallback-Methoden und der Socket-Empfangs-Thread laufen nicht auf + // dem Main-Thread - Flutters EventChannel.EventSink.success()/ + // MethodChannel.Result verlangen aber @UiThread (siehe Crash-Historie: + // "Methods marked with @UiThread must be executed on the main thread"). + // Deshalb jeden Ruecksprung nach Dart explizit ueber den Main-Looper. private val mainHandler = Handler(Looper.getMainLooper()) + private val socketExecutor = Executors.newSingleThreadExecutor() private var eventSink: EventChannel.EventSink? = null + private var socketEventSink: EventChannel.EventSink? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null private var currentNetwork: Network? = null + private var socket: DatagramSocket? = null + @Volatile + private var receiveLoopRunning = false + + /** Separater StreamHandler fuer den Socket-Daten-Kanal (siehe MainActivity). */ + val socketStreamHandler = object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { + socketEventSink = sink + } + + override fun onCancel(arguments: Any?) { + socketEventSink = null + } + } + override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) { eventSink = sink } @@ -79,21 +108,27 @@ class MlrsNetworkPlugin( releaseNetwork() result.success(null) } - "bindProcessToNetwork" -> { - val network = currentNetwork - if (network == null) { - result.success(false) - } else { - result.success(connectivityManager.bindProcessToNetwork(network)) - } - } - "unbindProcessFromNetwork" -> { - connectivityManager.bindProcessToNetwork(null) - result.success(null) - } "isStaConcurrencySupported" -> { result.success(isStaConcurrencySupported()) } + "openSocket" -> { + val port = call.argument("port") ?: 0 + openSocket(port, result) + } + "closeSocket" -> { + closeSocket() + result.success(null) + } + "sendPacket" -> { + val data = call.argument("data") + val host = call.argument("host") + val port = call.argument("port") + if (data == null || host == null || port == null) { + result.error("invalid_argument", "data/host/port erforderlich", null) + return + } + sendPacket(data, host, port, result) + } else -> result.notImplemented() } } @@ -161,4 +196,103 @@ class MlrsNetworkPlugin( ?: return null return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported } + + private fun openSocket(requestedPort: Int, result: MethodChannel.Result) { + val network = currentNetwork + if (network == null) { + result.error("no_network", "Kein mLRS-Netz verfuegbar, um den Socket zu binden.", null) + return + } + socketExecutor.execute { + try { + val newSocket = DatagramSocket(null) + // reuseAddress: false, damit ein tatsaechlich belegter Port + // hier erkennbar mit BindException scheitert (Doku 3/9: "ist + // 14550 belegt, ausweichen, aber protokollieren"). + newSocket.reuseAddress = false + var fallbackFromPort: Int? = null + try { + newSocket.bind(InetSocketAddress(requestedPort)) + } catch (e: BindException) { + newSocket.bind(InetSocketAddress(0)) + fallbackFromPort = requestedPort + } + + // Socket-genaue Bindung an das mLRS-Netz (siehe Klassendoku + // oben) - betrifft ausschliesslich diesen einen Socket, nicht + // den restlichen Prozess. + network.bindSocket(newSocket) + + socket = newSocket + startReceiveLoop(newSocket) + + val boundPort = newSocket.localPort + mainHandler.post { + result.success( + mapOf("boundPort" to boundPort, "fallbackFromPort" to fallbackFromPort), + ) + } + } catch (e: Exception) { + mainHandler.post { + result.error("socket_error", e.message, null) + } + } + } + } + + private fun startReceiveLoop(socket: DatagramSocket) { + receiveLoopRunning = true + Thread { + val buffer = ByteArray(RECEIVE_BUFFER_SIZE) + while (receiveLoopRunning) { + try { + val packet = DatagramPacket(buffer, buffer.size) + socket.receive(packet) + val data = packet.data.copyOfRange(packet.offset, packet.offset + packet.length) + val address = packet.address.hostAddress + val port = packet.port + mainHandler.post { + socketEventSink?.success( + mapOf("address" to address, "port" to port, "data" to data), + ) + } + } catch (e: Exception) { + // Socket wurde geschlossen (closeSocket()) oder das Netz + // ist weg - Netzverlust wird bereits ueber den + // Network-Events-Kanal gemeldet, hier reicht es, die + // Empfangsschleife zu beenden statt in eine Busy-Loop zu + // laufen. + break + } + } + }.apply { + isDaemon = true + start() + } + } + + private fun closeSocket() { + receiveLoopRunning = false + socket?.close() + socket = null + } + + private fun sendPacket(data: ByteArray, host: String, port: Int, result: MethodChannel.Result) { + val currentSocket = socket + if (currentSocket == null) { + mainHandler.post { + result.error("no_socket", "Kein offener Socket zum Senden.", null) + } + return + } + socketExecutor.execute { + try { + val address = InetAddress.getByName(host) + currentSocket.send(DatagramPacket(data, data.size, address, port)) + mainHandler.post { result.success(null) } + } catch (e: Exception) { + mainHandler.post { result.error("send_failed", e.message, null) } + } + } + } } diff --git a/app/lib/transport/udp/mlrs_network_controller.dart b/app/lib/transport/udp/mlrs_network_controller.dart index d526f83..12271f6 100644 --- a/app/lib/transport/udp/mlrs_network_controller.dart +++ b/app/lib/transport/udp/mlrs_network_controller.dart @@ -29,12 +29,48 @@ class MlrsNetworkLost extends MlrsNetworkEvent { const MlrsNetworkLost(); } +/// Ein per [MlrsNetworkController.openSocket] nativ gebundener UDP-Socket +/// erfolgreich geoeffnet. [boundPort] ist der tatsaechlich gebundene Port; +/// [fallbackFromPort] ist gesetzt, wenn der urspruenglich gewuenschte Port +/// belegt war und auf einen fluechtigen ausgewichen wurde (Doku 3/9). +class UdpSocketBindResult { + const UdpSocketBindResult({required this.boundPort, this.fallbackFromPort}); + + final int boundPort; + final int? fallbackFromPort; +} + +/// Ein vom nativen Socket empfangenes UDP-Paket. +class MlrsSocketPacket { + const MlrsSocketPacket({ + required this.address, + required this.port, + required this.data, + }); + + final String address; + final int port; + final Uint8List data; +} + /// Plattform-Bruecke zum app-initiierten WLAN-Verbindungsaufbau -/// (`WifiNetworkSpecifier`/`ConnectivityManager`, Doku Kommunikationsschicht -/// v2 Abschnitt 4) - kapselt den einzigen Platform-Channel-Zugriff dieses -/// Features. [UdpTransport] programmiert ausschliesslich gegen dieses -/// Interface, damit es in Tests durch eine Fake-Implementierung ersetzt -/// werden kann, ohne einen echten Android-Kanal zu brauchen. +/// (`WifiNetworkSpecifier`/`ConnectivityManager`) UND zum darauf gebundenen +/// UDP-Socket (Doku Kommunikationsschicht v2 Abschnitt 4) - kapselt den +/// einzigen Platform-Channel-Zugriff dieses Features. [UdpTransport] +/// programmiert ausschliesslich gegen dieses Interface, damit es in Tests +/// durch eine Fake-Implementierung ersetzt werden kann, ohne einen echten +/// Android-Kanal zu brauchen. +/// +/// Der UDP-Socket wird bewusst nativ gehalten und per `Network.bindSocket()` +/// **socket-genau** an das mLRS-Netz gebunden statt per +/// `ConnectivityManager.bindProcessToNetwork()` den gesamten Prozess zu +/// binden (Doku Abschnitt 4, Stufe 3) - Letzteres hat in der Praxis dazu +/// gefuehrt, dass gleichzeitige Kartenkacheln-/Wetter-HTTP-Requests, die +/// waehrend des kurzen Bindungsfensters neue Verbindungen aufbauten, auf dem +/// internetlosen mLRS-Netz haengen blieben (besonders auf Geraeten ohne +/// STA-Concurrency). Socket-genaue Bindung ist Androids eigene Empfehlung +/// fuer Apps, die gleichzeitig ein spezielles lokales Netz UND normales +/// Internet brauchen. abstract class MlrsNetworkController { /// Ergebnisse zur zuletzt per [requestNetwork] gestellten Anfrage - /// broadcast, damit sowohl [UdpTransport.connect] (wartet auf das erste @@ -51,15 +87,21 @@ abstract class MlrsNetworkController { /// zurueck - keine weiteren [events] danach, bis erneut angefragt wird. Future releaseNetwork(); - /// Bindet den App-Prozess kurzzeitig an das zuletzt verfuegbare Netz - /// (Doku Abschnitt 4: "Bindungsfenster"). Liefert false, wenn aktuell kein - /// Netz verfuegbar ist. - Future bindProcessToNetwork(); + /// Oeffnet einen nativ erzeugten UDP-Socket auf [port] und bindet ihn per + /// `Network.bindSocket()` an das zuletzt verfuegbare Netz. Weicht bei + /// belegtem Port auf einen fluechtigen aus (siehe [UdpSocketBindResult]). + /// Setzt voraus, dass zuvor ein [MlrsNetworkAvailable]-Ereignis empfangen + /// wurde. + Future openSocket(int port); - /// Loest die Prozessbindung wieder - muss unmittelbar nach dem Erzeugen - /// des UDP-Sockets aufgerufen werden, sonst laufen auch Karten/Wetter - /// ueber das mLRS-Netz statt der Standardroute. - Future unbindProcessFromNetwork(); + /// Schliesst den nativen Socket und beendet dessen Empfangsschleife. + Future closeSocket(); + + /// Sendet [data] ueber den nativen Socket an [host]:[port]. + Future send(Uint8List data, String host, int port); + + /// Eingehende UDP-Pakete vom nativen Socket. + Stream get incomingPackets; /// Ergebnis von /// `WifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported()` (Doku @@ -76,15 +118,22 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController { MethodChannelMlrsNetworkController() : _methodChannel = const MethodChannel('com.dmc.dmc_app/mlrs_network'), _eventChannel = - const EventChannel('com.dmc.dmc_app/mlrs_network_events'); + const EventChannel('com.dmc.dmc_app/mlrs_network_events'), + _socketEventChannel = + const EventChannel('com.dmc.dmc_app/mlrs_socket_events'); final MethodChannel _methodChannel; final EventChannel _eventChannel; + final EventChannel _socketEventChannel; @override late final Stream events = _eventChannel.receiveBroadcastStream().map(_toEvent); + @override + late final Stream incomingPackets = + _socketEventChannel.receiveBroadcastStream().map(_toPacket); + MlrsNetworkEvent _toEvent(dynamic raw) { final map = Map.from(raw as Map); return switch (map['type']) { @@ -96,6 +145,15 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController { }; } + MlrsSocketPacket _toPacket(dynamic raw) { + final map = Map.from(raw as Map); + return MlrsSocketPacket( + address: map['address'] as String, + port: map['port'] as int, + data: map['data'] as Uint8List, + ); + } + @override Future requestNetwork(String ssidPrefix) => _methodChannel .invokeMethod('requestNetwork', {'ssidPrefix': ssidPrefix}); @@ -104,13 +162,26 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController { Future releaseNetwork() => _methodChannel.invokeMethod('releaseNetwork'); @override - Future bindProcessToNetwork() async => - (await _methodChannel.invokeMethod('bindProcessToNetwork')) ?? - false; + Future openSocket(int port) async { + final result = await _methodChannel + .invokeMapMethod('openSocket', {'port': port}); + final map = result!; + return UdpSocketBindResult( + boundPort: map['boundPort'] as int, + fallbackFromPort: map['fallbackFromPort'] as int?, + ); + } @override - Future unbindProcessFromNetwork() => - _methodChannel.invokeMethod('unbindProcessFromNetwork'); + Future closeSocket() => _methodChannel.invokeMethod('closeSocket'); + + @override + Future send(Uint8List data, String host, int port) => + _methodChannel.invokeMethod('sendPacket', { + 'data': data, + 'host': host, + 'port': port, + }); @override Future isStaConcurrencySupported() => @@ -118,7 +189,7 @@ class MethodChannelMlrsNetworkController implements MlrsNetworkController { @override void dispose() { - // Keine eigenen Ressourcen - Abonnenten von [events] verwalten ihre - // StreamSubscription selbst (siehe UdpTransport.dispose). + // Keine eigenen Ressourcen - Abonnenten von [events]/[incomingPackets] + // verwalten ihre StreamSubscription selbst (siehe UdpTransport.dispose). } } diff --git a/app/lib/transport/udp/udp_transport.dart b/app/lib/transport/udp/udp_transport.dart index 54a0df8..8395804 100644 --- a/app/lib/transport/udp/udp_transport.dart +++ b/app/lib/transport/udp/udp_transport.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'dart:io' show InternetAddress; import 'package:flutter/services.dart'; @@ -17,9 +17,11 @@ import 'mlrs_network_controller.dart'; /// `unavailable` warten (Doku Abschnitt 4). /// 2. Zieladresse vorbelegen: konfigurierter Host, sonst die vom Netz /// gemeldete Gateway-Adresse, sonst ungelernt (Doku Abschnitt 3). -/// 3. Kurzes Bindungsfenster: Prozess an das Netz binden, UDP-Socket -/// erzeugen, Prozessbindung sofort wieder loesen (Doku Abschnitt 4) - der -/// Socket selbst bleibt danach am WLAN gebunden. +/// 3. Nativen UDP-Socket oeffnen, der **socket-genau** (nicht prozessweit) +/// per `Network.bindSocket()` an das mLRS-Netz gebunden ist (Doku +/// Abschnitt 4, Stufe 3) - siehe [MlrsNetworkController]-Doku fuer die +/// Begruendung, warum Prozessbindung hier in der Praxis Kartenkacheln/ +/// Wetter-Requests beschaedigt hat. /// 4. Weiterhin auf das Netz hoeren: geht es verloren (`lost`), ist der /// Socket ungueltig (Doku Abschnitt 8) und die Verbindung wird mit /// [LinkErrorReason.networkLost] beendet. Automatisches Wiederverbinden @@ -45,12 +47,12 @@ class UdpTransport implements LinkTransport { final _incomingController = StreamController.broadcast(); final _stateController = StreamController.broadcast(); - RawDatagramSocket? _socket; - StreamSubscription? _socketSub; + StreamSubscription? _packetSub; StreamSubscription? _networkLossSub; InternetAddress? _peerAddress; int? _peerPort; int? _boundPort; + bool _socketOpen = false; bool? _staConcurrencySupported; LinkState _state = LinkState.disconnected; @@ -109,8 +111,8 @@ class UdpTransport implements LinkTransport { } final available = event; - // Ab hier auf Netzverlust reagieren - auch waehrend des kurzen - // Bindungsfensters unten, nicht erst danach (Doku Abschnitt 8). + // Ab hier auf Netzverlust reagieren - auch waehrend des Socket-Aufbaus + // unten, nicht erst danach (Doku Abschnitt 8). _networkLossSub = _networkController.events.listen(_onNetworkEvent); final configuredHost = await _getHost(); @@ -126,45 +128,23 @@ class UdpTransport implements LinkTransport { } } - await _networkController.bindProcessToNetwork(); - RawDatagramSocket socket; - try { - // reuseAddress: false, damit ein tatsaechlich belegter Port hier - // erkennbar mit SocketException scheitert - Darts Standard - // (reuseAddress: true) wuerde den Konflikt sonst still zulassen. - socket = await RawDatagramSocket.bind( - InternetAddress.anyIPv4, - configuredPort, - reuseAddress: false, - ); - } on SocketException { - // Port belegt (Doku 3/9: "ist 14550 belegt, ausweichen, aber - // protokollieren") - Ausweichen ist kein Grund, abzubrechen. - socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); - _lastErrorMessage = - 'Port $configuredPort in use, fell back to ${socket.port}.'; - } finally { - await _networkController.unbindProcessFromNetwork(); + // Nativer, socket-genau gebundener UDP-Socket statt Prozessbindung + // (siehe Klassendoku) - der eigentliche RawDatagramSocket lebt in + // MlrsNetworkPlugin.kt, hier kommen nur noch Bytes ueber den + // EventChannel an. + final bindResult = await _networkController.openSocket(configuredPort); + _socketOpen = true; + _boundPort = bindResult.boundPort; + if (bindResult.fallbackFromPort != null) { + _lastErrorMessage = 'Port ${bindResult.fallbackFromPort} in use, ' + 'fell back to ${bindResult.boundPort}.'; } - - _socket = socket; - _boundPort = socket.port; - _socketSub = socket.listen( - _onSocketEvent, - onError: (Object e) => - _setError(LinkErrorReason.connectionFailed, e.toString()), - ); + _packetSub = _networkController.incomingPackets.listen(_onPacket); _staConcurrencySupported = await _networkController.isStaConcurrencySupported(); _setState(LinkState.connected); - } on SocketException catch (e) { - await requestSub.cancel(); - await _networkLossSub?.cancel(); - _networkLossSub = null; - await _networkController.releaseNetwork(); - _fail(LinkErrorReason.connectionFailed, e.message); } on MissingPluginException { await requestSub.cancel(); _fail( @@ -173,9 +153,12 @@ class UdpTransport implements LinkTransport { ); } on PlatformException catch (e) { await requestSub.cancel(); + await _networkLossSub?.cancel(); + _networkLossSub = null; + await _networkController.releaseNetwork(); _fail( - LinkErrorReason.unknown, - e.message ?? 'Platform error requesting the mLRS network.', + LinkErrorReason.connectionFailed, + e.message ?? 'Platform error while opening the mLRS socket.', ); } } @@ -184,59 +167,61 @@ class UdpTransport implements LinkTransport { if (event is! MlrsNetworkLost) return; _networkLossSub?.cancel(); _networkLossSub = null; - _socketSub?.cancel(); - _socketSub = null; - _socket?.close(); - _socket = null; + _packetSub?.cancel(); + _packetSub = null; _boundPort = null; + if (_socketOpen) { + _socketOpen = false; + unawaited(_networkController.closeSocket()); + } _setError( LinkErrorReason.networkLost, 'The mLRS WiFi network was lost.', ); } - void _onSocketEvent(RawSocketEvent event) { - if (event != RawSocketEvent.read) return; - final datagram = _socket?.receive(); - if (datagram == null) return; - _peerAddress = datagram.address; - _peerPort = datagram.port; - _incomingController.add(datagram.data); + void _onPacket(MlrsSocketPacket packet) { + _peerAddress = InternetAddress.tryParse(packet.address) ?? _peerAddress; + _peerPort = packet.port; + _incomingController.add(packet.data); } @override Future disconnect() async { await _networkLossSub?.cancel(); _networkLossSub = null; - await _socketSub?.cancel(); - _socketSub = null; - _socket?.close(); - _socket = null; + await _packetSub?.cancel(); + _packetSub = null; _boundPort = null; + if (_socketOpen) { + _socketOpen = false; + await _networkController.closeSocket(); + } await _networkController.releaseNetwork(); _setState(LinkState.disconnected); } @override Future send(Uint8List data) async { - final socket = _socket; final peer = _peerAddress; final port = _peerPort; - if (socket == null || peer == null || port == null) { + if (!_socketOpen || peer == null || port == null) { throw const LinkConnectException( LinkErrorReason.connectionFailed, 'send() ohne aktive Verbindung oder bekannte Gegenstelle aufgerufen.', ); } - socket.send(data, peer, port); + await _networkController.send(data, peer.address, port); } @override void dispose() { _networkLossSub?.cancel(); - _socketSub?.cancel(); - _socket?.close(); - _socket = null; + _packetSub?.cancel(); + if (_socketOpen) { + _socketOpen = false; + unawaited(_networkController.closeSocket()); + } unawaited(_networkController.releaseNetwork()); _networkController.dispose(); _incomingController.close(); diff --git a/app/test/transport/udp_transport_test.dart b/app/test/transport/udp_transport_test.dart index d0a2c75..f1ded2a 100644 --- a/app/test/transport/udp_transport_test.dart +++ b/app/test/transport/udp_transport_test.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; @@ -9,24 +8,34 @@ import 'package:dmc_app/transport/udp/mlrs_network_controller.dart'; import 'package:dmc_app/transport/udp/udp_transport.dart'; /// Test-Doppel fuer [MlrsNetworkController] (Doku Kommunikationsschicht v2, -/// Abschnitt 4): simuliert Netzanfrage-Ergebnisse ueber [emit], ohne einen -/// echten Android-Platform-Channel zu brauchen. Zeichnet Aufrufe auf, damit -/// Tests den Bindungsfenster-Ablauf (bind -> Socket -> unbind) pruefen -/// koennen. +/// Abschnitt 4): simuliert Netzanfrage- und Socket-Ergebnisse rein in Dart, +/// ohne einen echten Android-Platform-Channel/nativen Socket zu brauchen. +/// Der eigentliche `DatagramSocket` lebt seit der Umstellung auf +/// socket-genaue Bindung (statt Prozessbindung) nativ in +/// MlrsNetworkPlugin.kt - dieses Fake deckt daher nur noch die +/// Orchestrierung in UdpTransport ab, nicht mehr echtes UDP-Verhalten. class FakeMlrsNetworkController implements MlrsNetworkController { final _eventsController = StreamController.broadcast(); + final _packetsController = StreamController.broadcast(); final List requestedSsidPrefixes = []; + final List<({Uint8List data, String host, int port})> sentPackets = []; int releaseCount = 0; - int bindCount = 0; - int unbindCount = 0; - bool bindResult = true; + int openSocketCount = 0; + int closeSocketCount = 0; bool? staConcurrencyResult; + int nextBoundPort = 14550; + int? nextFallbackFromPort; + @override Stream get events => _eventsController.stream; + @override + Stream get incomingPackets => _packetsController.stream; + void emit(MlrsNetworkEvent event) => _eventsController.add(event); + void emitPacket(MlrsSocketPacket packet) => _packetsController.add(packet); @override Future requestNetwork(String ssidPrefix) async { @@ -39,14 +48,22 @@ class FakeMlrsNetworkController implements MlrsNetworkController { } @override - Future bindProcessToNetwork() async { - bindCount++; - return bindResult; + Future openSocket(int port) async { + openSocketCount++; + return UdpSocketBindResult( + boundPort: nextBoundPort, + fallbackFromPort: nextFallbackFromPort, + ); } @override - Future unbindProcessFromNetwork() async { - unbindCount++; + Future closeSocket() async { + closeSocketCount++; + } + + @override + Future send(Uint8List data, String host, int port) async { + sentPackets.add((data: data, host: host, port: port)); } @override @@ -55,6 +72,7 @@ class FakeMlrsNetworkController implements MlrsNetworkController { @override void dispose() { _eventsController.close(); + _packetsController.close(); } } @@ -96,16 +114,14 @@ void main() { transport.dispose(); }); - test( - 'connect() binds process to network, creates the socket, then unbinds', + test('connect() opens the native socket and reports the bound port', () async { - final fake = FakeMlrsNetworkController(); + final fake = FakeMlrsNetworkController()..nextBoundPort = 14550; final transport = await connectWithFake(fake); expect(transport.currentState, LinkState.connected); - expect(transport.boundPort, isNotNull); - expect(fake.bindCount, 1); - expect(fake.unbindCount, 1); + expect(transport.boundPort, 14550); + expect(fake.openSocketCount, 1); transport.dispose(); }); @@ -130,6 +146,7 @@ void main() { ); expect(transport.lastErrorReason, LinkErrorReason.networkUnavailable); expect(transport.currentState, LinkState.error); + expect(fake.openSocketCount, 0); transport.dispose(); }); @@ -145,6 +162,7 @@ void main() { expect(transport.currentState, LinkState.error); expect(transport.lastErrorReason, LinkErrorReason.networkLost); expect(transport.boundPort, isNull); + expect(fake.closeSocketCount, 1); transport.dispose(); }); @@ -178,24 +196,22 @@ void main() { test('incoming packets arrive via incoming and learn the peer', () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); - final sender = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); final received = []; transport.incoming.listen(received.add); - sender.send( - Uint8List.fromList([1, 2, 3]), - InternetAddress.loopbackIPv4, - transport.boundPort!, - ); - await Future.delayed(const Duration(milliseconds: 50)); + fake.emitPacket(MlrsSocketPacket( + address: '192.168.4.100', + port: 5555, + data: Uint8List.fromList([1, 2, 3]), + )); + await pump(); expect(received, [ Uint8List.fromList([1, 2, 3]), ]); - expect(transport.peerDescription, contains(sender.port.toString())); + expect(transport.peerDescription, '192.168.4.100:5555'); - sender.close(); transport.dispose(); }); @@ -211,37 +227,25 @@ void main() { transport.dispose(); }); - test('send() sends bytes to the learned peer', () async { + test('send() forwards bytes to the learned peer via the native socket', + () async { final fake = FakeMlrsNetworkController(); final transport = await connectWithFake(fake); - final peer = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0); - // Erst "sprechen" lassen, damit der Transport die Gegenstelle lernt - // (Doku Kommunikationsschicht v2 Abschnitt 3: "Gegenstelle aus dem - // Verkehr lernen"). - peer.send( - Uint8List.fromList([9]), - InternetAddress.loopbackIPv4, - transport.boundPort!, - ); - await Future.delayed(const Duration(milliseconds: 50)); - - final receivedByPeer = []; - peer.listen((event) { - if (event == RawSocketEvent.read) { - final datagram = peer.receive(); - if (datagram != null) receivedByPeer.add(datagram.data); - } - }); + fake.emitPacket(MlrsSocketPacket( + address: '192.168.4.100', + port: 5555, + data: Uint8List.fromList([9]), + )); + await pump(); await transport.send(Uint8List.fromList([7, 7, 7])); - await Future.delayed(const Duration(milliseconds: 50)); - expect(receivedByPeer, [ - Uint8List.fromList([7, 7, 7]), - ]); + expect(fake.sentPackets, hasLength(1)); + expect(fake.sentPackets.single.data, Uint8List.fromList([7, 7, 7])); + expect(fake.sentPackets.single.host, '192.168.4.100'); + expect(fake.sentPackets.single.port, 5555); - peer.close(); transport.dispose(); }); @@ -254,23 +258,23 @@ void main() { expect(transport.currentState, LinkState.disconnected); expect(transport.boundPort, isNull); + expect(fake.closeSocketCount, 1); expect(fake.releaseCount, greaterThanOrEqualTo(1)); transport.dispose(); }); - test( - 'falls back to an ephemeral port when the configured one is occupied', + test('reports a fallback port when the configured one was occupied natively', () async { - final occupied = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); - final fake = FakeMlrsNetworkController(); - final transport = await connectWithFake(fake, port: occupied.port); + final fake = FakeMlrsNetworkController() + ..nextBoundPort = 51000 + ..nextFallbackFromPort = 14550; + final transport = await connectWithFake(fake, port: 14550); expect(transport.currentState, LinkState.connected); - expect(transport.boundPort, isNot(occupied.port)); + expect(transport.boundPort, 51000); expect(transport.lastErrorMessage, contains('in use')); - occupied.close(); transport.dispose(); });